From 6ed70e24c7155c5cf7199c01120e435c6004aed5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Feb 2026 23:11:41 +0700 Subject: [PATCH 1/7] feat: implement end-to-end encryption for relay connections --- CLAUDE.md | 8 - package-lock.json | 389 +++++++++++++++++- packages/app/e2e/global-setup.ts | 1 + .../app/e2e/relay-fallback-connect.spec.ts | 8 +- packages/app/metro.config.js | 3 +- packages/app/package.json | 3 + .../components/multi-daemon-session-host.tsx | 27 +- packages/app/src/contexts/session-context.tsx | 4 +- packages/app/src/hooks/use-daemon-client.ts | 29 +- packages/app/src/polyfills/crypto.ts | 36 +- packages/relay/package.json | 2 + packages/relay/src/base64.ts | 12 + packages/relay/src/crypto.ts | 23 +- packages/relay/src/encrypted-channel.ts | 22 +- .../server/src/client/daemon-client-v2.ts | 219 +++++++++- packages/server/src/server/bootstrap.ts | 4 + .../server/src/server/connection-offer.ts | 17 +- .../daemon-e2e/relay-transport.e2e.test.ts | 72 +++- packages/server/src/server/daemon-keypair.ts | 78 ++++ packages/server/src/server/relay-transport.ts | 132 +++++- .../server/src/server/websocket-server.ts | 4 +- .../src/server/websocket-session-bridge.ts | 24 +- packages/server/tsconfig.server.json | 2 +- 23 files changed, 1004 insertions(+), 115 deletions(-) create mode 100644 packages/relay/src/base64.ts create mode 100644 packages/server/src/server/daemon-keypair.ts diff --git a/CLAUDE.md b/CLAUDE.md index 21bbe4d0c..21285a096 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,14 +102,6 @@ Get the session ID from the agent JSON file (`persistence.sessionId`), then: Take screenshots like this: `adb exec-out screencap -p > screenshot.png` -## Testing with Playwright MCP - -**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. - -Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. - -**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. - ## Expo troubleshooting Run `npx expo-doctor` to diagnose version mismatches and native module issues. diff --git a/package-lock.json b/package-lock.json index 4499138bc..b80a6c87f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6720,6 +6720,51 @@ "resolved": "packages/website", "link": true }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -8086,6 +8131,47 @@ "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", "license": "CC0-1.0" }, + "node_modules/@sphereon/isomorphic-webcrypto": { + "version": "2.5.0-rn-crypto.2", + "resolved": "https://registry.npmjs.org/@sphereon/isomorphic-webcrypto/-/isomorphic-webcrypto-2.5.0-rn-crypto.2.tgz", + "integrity": "sha512-Vr5KcPTQ6LbKyCOLEe6DTPxMXeESYBDw/gqbjyynUtqmNtBz6dcccP3HpM9TjlwqP8c/uPEtnrxA9V81/FH8HA==", + "license": "MIT", + "dependencies": { + "@peculiar/webcrypto": "^1.5.0", + "asmcrypto.js": "^2.3.2", + "b64-lite": "^1.4.0", + "b64u-lite": "^1.1.0", + "cipher-base": "^1.0.4", + "create-hash": "^1.2.0", + "inherits": "^2.0.4", + "md5.js": "^1.3.5", + "randomfill": "^1.0.4", + "ripemd160": "^2.0.2", + "sha.js": "^2.4.11", + "str2buf": "^1.3.0", + "webcrypto-shim": "^0.1.7" + }, + "peerDependencies": { + "expo": "*", + "expo-crypto": "*", + "msrcrypto": "^1.5.8", + "react-native-securerandom": "^1.0.1" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-crypto": { + "optional": true + }, + "msrcrypt": { + "optional": true + }, + "react-native-securerandom": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -10661,6 +10747,12 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/asmcrypto.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", + "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -10671,6 +10763,26 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asn1js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -10794,6 +10906,24 @@ } } }, + "node_modules/b64-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/b64-lite/-/b64-lite-1.4.0.tgz", + "integrity": "sha512-aHe97M7DXt+dkpa8fHlCcm1CnskAHrJqEfMI0KN7dwqlzml/aUe1AGt6lk51HzrSfVD67xOso84sOpr+0wIe2w==", + "license": "MIT", + "dependencies": { + "base-64": "^0.1.0" + } + }, + "node_modules/b64u-lite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/b64u-lite/-/b64u-lite-1.1.0.tgz", + "integrity": "sha512-929qWGDVCRph7gQVTC6koHqQIpF4vtVaSbwLltFQo44B1bYUquALswZdBKFfrJCPEnsCOvWkJsPdQYZ/Ukhw8A==", + "license": "MIT", + "dependencies": { + "b64-lite": "^1.4.0" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -11042,6 +11172,11 @@ } } }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -11688,6 +11823,20 @@ "node": ">=8" } }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -12084,6 +12233,19 @@ "node": ">= 0.10" } }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -16118,6 +16280,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -17022,7 +17199,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isbot": { @@ -18213,6 +18389,17 @@ "is-buffer": "~1.1.6" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -19005,6 +19192,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msrcrypto": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/msrcrypto/-/msrcrypto-1.5.8.tgz", + "integrity": "sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/multipasta": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", @@ -20456,6 +20650,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -20573,6 +20773,30 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvtsutils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -20817,6 +21041,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -21224,6 +21467,18 @@ "react-native": "*" } }, + "node_modules/react-native-securerandom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-native-securerandom/-/react-native-securerandom-1.0.1.tgz", + "integrity": "sha512-ibuDnd3xi17HyD5CkilOXGPFpS9Z1oifjyHFwUl8NMzcQcpruM0ZX8ytr3A4rCeAsaBHjz69r78Xgd6vUswv1Q==", + "license": "MIT", + "dependencies": { + "base64-js": "*" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-svg": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz", @@ -21587,6 +21842,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -22007,6 +22289,19 @@ "node": "*" } }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", @@ -22495,6 +22790,26 @@ "node": ">=10" } }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -23307,6 +23622,12 @@ "npm": ">=6" } }, + "node_modules/str2buf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/str2buf/-/str2buf-1.3.0.tgz", + "integrity": "sha512-xIBmHIUHYZDP4HyoXGHYNVmxlXLXDrtFHYT0eV6IOdEj3VO9ccaF1Ejl9Oq8iFjITllpT8FhaXb4KsNmw+3EuA==", + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -23337,6 +23658,21 @@ "node": ">=4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -24002,6 +24338,20 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -24312,7 +24662,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -24737,6 +25086,12 @@ "which-typed-array": "^1.1.2" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -25133,6 +25488,31 @@ "node": ">= 14" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, + "node_modules/webcrypto-core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/webcrypto-shim": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/webcrypto-shim/-/webcrypto-shim-0.1.7.tgz", + "integrity": "sha512-JAvAQR5mRNRxZW2jKigWMjCMkjSdmP5cColRP1U/pTg69VgHXEi1orv5vVpJ55Zc5MIaPc1aaurzd9pjv2bveg==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -26286,8 +26666,10 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2", "@tanstack/react-query": "^5.90.11", "@tauri-apps/api": "^2.9.1", + "base64-js": "^1.5.1", "buffer": "^6.0.3", "expo": "^54.0.18", "expo-audio": "~1.0.13", @@ -26330,6 +26712,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-securerandom": "^1.0.1", "react-native-svg": "^15.14.0", "react-native-unistyles": "^3.0.15", "react-native-web": "~0.21.0", @@ -26439,6 +26822,8 @@ "name": "@paseo/relay", "version": "0.1.0", "dependencies": { + "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2", + "base64-js": "^1.5.1", "ws": "^8.14.2" }, "devDependencies": { diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 8357ac481..e3937301c 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -184,6 +184,7 @@ export default async function globalSetup() { process.env.E2E_DAEMON_PORT = String(port); process.env.E2E_RELAY_PORT = String(relayPort); process.env.E2E_RELAY_SESSION_ID = offer.sessionId; + process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_METRO_PORT = String(metroPort); console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`); diff --git a/packages/app/e2e/relay-fallback-connect.spec.ts b/packages/app/e2e/relay-fallback-connect.spec.ts index c2d3fec58..61e2f61f1 100644 --- a/packages/app/e2e/relay-fallback-connect.spec.ts +++ b/packages/app/e2e/relay-fallback-connect.spec.ts @@ -3,8 +3,11 @@ import { test, expect } from './fixtures'; test('connects via relay when direct endpoints fail', async ({ page }) => { const relayPort = process.env.E2E_RELAY_PORT; const sessionId = process.env.E2E_RELAY_SESSION_ID; - if (!relayPort || !sessionId) { - throw new Error('E2E_RELAY_PORT or E2E_RELAY_SESSION_ID is not set (expected from globalSetup).'); + const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY; + if (!relayPort || !sessionId || !daemonPublicKeyB64) { + throw new Error( + 'E2E_RELAY_PORT, E2E_RELAY_SESSION_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).' + ); } const nowIso = new Date().toISOString(); @@ -14,6 +17,7 @@ test('connects via relay when direct endpoints fail', async ({ page }) => { id: 'relay-only-daemon', label: 'relay-daemon', endpoints: [relayEndpoint], + daemonPublicKeyB64, relay: { endpoint: relayEndpoint, sessionId }, metadata: null, createdAt: nowIso, diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js index 86f1dc22d..456a5ec4e 100644 --- a/packages/app/metro.config.js +++ b/packages/app/metro.config.js @@ -5,6 +5,7 @@ const path = require("path"); const projectRoot = __dirname; const serverSrcRoot = path.resolve(projectRoot, "../server/src"); +const relaySrcRoot = path.resolve(projectRoot, "../relay/src"); const config = getDefaultConfig(projectRoot); const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; @@ -13,7 +14,7 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { const origin = context.originModulePath; if ( origin && - origin.startsWith(serverSrcRoot) && + (origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) && moduleName.endsWith(".js") ) { const tsModuleName = moduleName.replace(/\.js$/, ".ts"); diff --git a/packages/app/package.json b/packages/app/package.json index 7bac97b3d..7050a9d3f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -41,8 +41,10 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2", "@tanstack/react-query": "^5.90.11", "@tauri-apps/api": "^2.9.1", + "base64-js": "^1.5.1", "buffer": "^6.0.3", "expo": "^54.0.18", "expo-audio": "~1.0.13", @@ -85,6 +87,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-securerandom": "^1.0.1", "react-native-svg": "^15.14.0", "react-native-unistyles": "^3.0.15", "react-native-web": "~0.21.0", diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index a896a7d13..2aeb3a60d 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; function buildCandidateUrls(daemon: HostProfile): string[] { const endpoints = daemon.endpoints ?? []; const sessionId = daemon.relay?.sessionId ?? null; + const relayEndpoint = daemon.relay?.endpoint ?? null; const lastKnownGood = typeof daemon.metadata?.lastKnownGoodEndpoint === "string" ? (daemon.metadata.lastKnownGoodEndpoint as string) @@ -20,21 +21,24 @@ function buildCandidateUrls(daemon: HostProfile): string[] { if (!out.includes(url)) out.push(url); }; - if (lastKnownGood) { + const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint; + const directEndpoints = relayEndpoint + ? endpoints.filter((endpoint) => endpoint !== relayEndpoint) + : endpoints; + + if (lastKnownGood && !isLastKnownRelay) { push(buildDaemonWebSocketUrl(lastKnownGood)); } - for (const endpoint of endpoints) { + for (const endpoint of directEndpoints) { push(buildDaemonWebSocketUrl(endpoint)); } - if (sessionId) { - if (lastKnownGood) { - push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId })); - } - for (const endpoint of endpoints) { - push(buildRelayWebSocketUrl({ endpoint, sessionId })); + if (sessionId && relayEndpoint) { + if (isLastKnownRelay) { + push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); } + push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); } return out; @@ -123,7 +127,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { ]); return ( - + {null} ); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d60ea2f49..f371cd0ac 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -326,6 +326,7 @@ interface SessionProviderProps { children: ReactNode; serverUrl: string; serverId: string; + daemonPublicKeyB64?: string; } // SessionProvider: Daemon client message handler that updates Zustand store @@ -333,8 +334,9 @@ export function SessionProvider({ children, serverUrl, serverId, + daemonPublicKeyB64, }: SessionProviderProps) { - const client = useDaemonClient(serverUrl); + const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 }); const [connectionSnapshot, setConnectionSnapshot] = useState(() => mapConnectionState(client.getConnectionState(), client.lastError) diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts index 9bd6e78eb..f3ac56f45 100644 --- a/packages/app/src/hooks/use-daemon-client.ts +++ b/packages/app/src/hooks/use-daemon-client.ts @@ -9,19 +9,44 @@ function runDaemonRequest(label: string, promise: Promise): void { }); } -export function useDaemonClient(url: string): DaemonClientV2 { +type DaemonClientOptions = { + daemonPublicKeyB64?: string; +}; + +function isRelayWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("session"); + } catch { + return false; + } +} + +export function useDaemonClient( + url: string, + options: DaemonClientOptions = {} +): DaemonClientV2 { const client = useMemo( () => { const tauriTransportFactory = createTauriWebSocketTransportFactory(); + const relayConnection = isRelayWebSocketUrl(url); return new DaemonClientV2({ url, suppressSendErrors: true, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), + ...(relayConnection + ? { + e2ee: { + enabled: true, + daemonPublicKeyB64: options.daemonPublicKeyB64, + }, + } + : {}), }); }, - [url] + [options.daemonPublicKeyB64, url] ); useEffect(() => { diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index 81678babe..e327e99fc 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -7,16 +7,44 @@ declare global { } export function polyfillCrypto(): void { - if (typeof globalThis.crypto === "undefined") { - (globalThis as any).crypto = {}; + let webcrypto: Crypto | null = null; + try { + // Prefer the React Native entrypoint to avoid the browser shim. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const rnModule = require("@sphereon/isomorphic-webcrypto/src/react-native"); + webcrypto = (rnModule?.default ?? rnModule) as Crypto; + } catch { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fallbackModule = require("@sphereon/isomorphic-webcrypto"); + webcrypto = (fallbackModule?.default ?? fallbackModule) as Crypto; } - if (typeof globalThis.crypto.randomUUID !== "function") { + const existing = (globalThis as any).crypto as Crypto | null | undefined; + let target = existing; + if (!target || typeof (target as Crypto).subtle === "undefined") { + target = (webcrypto && typeof webcrypto === "object" ? (webcrypto as Crypto) : undefined) ?? ({} as Crypto); + (globalThis as any).crypto = target; + } + + const ensureSecure = (globalThis as any).crypto?.ensureSecure as + | (() => Promise) + | undefined; + if (typeof ensureSecure === "function") { + void ensureSecure(); + } + + if (typeof (globalThis as any).crypto?.randomUUID !== "function") { + if (!globalThis.crypto) { + (globalThis as any).crypto = {} as Crypto; + } globalThis.crypto.randomUUID = () => ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`; } - if (typeof globalThis.crypto.getRandomValues !== "function") { + if (typeof (globalThis as any).crypto?.getRandomValues !== "function") { + if (!globalThis.crypto) { + (globalThis as any).crypto = {} as Crypto; + } globalThis.crypto.getRandomValues = (array: T): T => { return ExpoCrypto.getRandomValues(array as any) as T; }; diff --git a/packages/relay/package.json b/packages/relay/package.json index 31205c40a..b0973d931 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -14,6 +14,8 @@ "test:watch": "vitest" }, "dependencies": { + "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2", + "base64-js": "^1.5.1", "ws": "^8.14.2" }, "devDependencies": { diff --git a/packages/relay/src/base64.ts b/packages/relay/src/base64.ts new file mode 100644 index 000000000..f50222276 --- /dev/null +++ b/packages/relay/src/base64.ts @@ -0,0 +1,12 @@ +import { fromByteArray, toByteArray } from "base64-js"; + +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + return fromByteArray(new Uint8Array(buffer)); +} + +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const bytes = toByteArray(base64); + const out = new Uint8Array(bytes.byteLength); + out.set(bytes); + return out.buffer; +} diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 8176b0e34..6d1a2e2b0 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -1,3 +1,4 @@ +/// /** * E2EE crypto primitives using WebCrypto API. * @@ -6,6 +7,8 @@ * - AES-256-GCM for authenticated encryption */ +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; + const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" }; const AES_ALGORITHM = { name: "AES-GCM", length: 256 }; const IV_LENGTH = 12; @@ -127,23 +130,3 @@ export async function decrypt( return plaintext; } } - -// --- Helpers --- - -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 03e9d1fea..278364795 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -1,3 +1,4 @@ +/// /** * Encrypted channel that wraps a WebSocket-like transport. * @@ -13,6 +14,7 @@ import { encrypt, decrypt, } from "./crypto.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; export interface Transport { send(data: string | ArrayBuffer): void; @@ -193,23 +195,3 @@ export class EncryptedChannel { return this.state === "open"; } } - -// --- Helpers --- - -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 1cc400a4e..a9ab4f5d8 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -53,6 +53,11 @@ import type { AgentSessionConfig, } from "../server/agent/agent-sdk-types.js"; import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js"; +import { + createClientChannel, + type EncryptedChannel, + type Transport as RelayTransport, +} from "@paseo/relay"; export interface Logger { debug(obj: object, msg?: string): void; @@ -146,6 +151,10 @@ export type DaemonClientV2Config = { transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; logger?: Logger; + e2ee?: { + enabled?: boolean; + daemonPublicKeyB64?: string; + }; reconnect?: { enabled?: boolean; baseDelayMs?: number; @@ -305,11 +314,19 @@ export class DaemonClientV2 { try { this.cleanupTransport(); - const transportFactory = + const baseTransportFactory = this.config.transportFactory ?? createWebSocketTransportFactory( this.config.webSocketFactory ?? defaultWebSocketFactory ); + const transportFactory = + this.config.e2ee?.enabled === true + ? createEncryptedTransportFactory( + baseTransportFactory, + this.config.e2ee?.daemonPublicKeyB64, + this.logger + ) + : baseTransportFactory; const transport = transportFactory({ url: this.config.url, headers }); this.transport = transport; @@ -2325,6 +2342,206 @@ function createWebSocketTransportFactory( }; } +function createEncryptedTransportFactory( + baseFactory: DaemonTransportFactory, + daemonPublicKeyB64: string | undefined, + logger: Logger +): DaemonTransportFactory { + if (!daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required for relay E2EE"); + } + + return ({ url, headers }) => { + const base = baseFactory({ url, headers }); + return createEncryptedTransport(base, daemonPublicKeyB64, logger); + }; +} + +function createEncryptedTransport( + base: DaemonTransport, + daemonPublicKeyB64: string, + logger: Logger +): DaemonTransport { + let channel: EncryptedChannel | null = null; + let opened = false; + let closed = false; + + const openHandlers = new Set<() => void>(); + const closeHandlers = new Set<(event?: unknown) => void>(); + const errorHandlers = new Set<(event?: unknown) => void>(); + const messageHandlers = new Set<(data: unknown) => void>(); + + const emitOpen = () => { + if (opened || closed) return; + opened = true; + for (const handler of openHandlers) { + try { + handler(); + } catch { + // no-op + } + } + }; + + const emitClose = (event?: unknown) => { + if (closed) return; + closed = true; + for (const handler of closeHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitError = (event?: unknown) => { + if (closed) return; + for (const handler of errorHandlers) { + try { + handler(event); + } catch { + // no-op + } + } + }; + + const emitMessage = (data: unknown) => { + if (closed) return; + for (const handler of messageHandlers) { + try { + handler(data); + } catch { + // no-op + } + } + }; + + const relayTransport: RelayTransport = { + send: (data) => { + if (typeof data === "string") { + base.send(data); + return; + } + if (data instanceof ArrayBuffer) { + if (typeof TextDecoder !== "undefined") { + base.send(new TextDecoder().decode(data)); + return; + } + if (typeof Buffer !== "undefined") { + base.send(Buffer.from(data).toString("utf8")); + return; + } + base.send(String(data)); + return; + } + base.send(String(data)); + }, + close: (code?: number, reason?: string) => base.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + const startHandshake = async () => { + try { + channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { + onopen: emitOpen, + onmessage: (data) => emitMessage(data), + onclose: (code, reason) => emitClose({ code, reason }), + onerror: (error) => emitError(error), + }); + } catch (error) { + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + emitError(error); + base.close(1011, "E2EE handshake failed"); + } + }; + + base.onOpen(() => { + void startHandshake(); + }); + base.onMessage((event) => { + relayTransport.onmessage?.(extractRelayMessageData(event)); + }); + base.onClose((event) => { + const record = event as { code?: number; reason?: string } | undefined; + relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? ""); + emitClose(event); + }); + base.onError((event) => { + relayTransport.onerror?.( + event instanceof Error ? event : new Error(String(event)) + ); + emitError(event); + }); + + return { + send: (data) => { + if (!channel) { + throw new Error("Encrypted channel not ready"); + } + void channel.send(data).catch((error) => { + emitError(error); + }); + }, + close: (code?: number, reason?: string) => { + if (channel) { + channel.close(code, reason); + } else { + base.close(code, reason); + } + emitClose({ code, reason }); + }, + onMessage: (handler) => { + messageHandlers.add(handler); + return () => messageHandlers.delete(handler); + }, + onOpen: (handler) => { + openHandlers.add(handler); + if (opened) { + try { + handler(); + } catch { + // no-op + } + } + return () => openHandlers.delete(handler); + }, + onClose: (handler) => { + closeHandlers.add(handler); + if (closed) { + try { + handler(); + } catch { + // no-op + } + } + return () => closeHandlers.delete(handler); + }, + onError: (handler) => { + errorHandlers.add(handler); + return () => errorHandlers.delete(handler); + }, + }; +} + +function extractRelayMessageData(event: unknown): string | ArrayBuffer { + const raw = + event && typeof event === "object" && "data" in event + ? (event as { data: unknown }).data + : event; + if (typeof raw === "string") return raw; + if (raw instanceof ArrayBuffer) return raw; + if (ArrayBuffer.isView(raw)) { + const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + return String(raw ?? ""); +} + function bindWsHandler( ws: WebSocketLike, event: "open" | "close" | "error" | "message", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f9a86edd5..8bffecd58 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -53,6 +53,7 @@ import { createConnectionOfferV1, encodeOfferToFragmentUrl, } from "./connection-offer.js"; +import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { printPairingQrIfEnabled } from "./pairing-qr.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import type { @@ -102,6 +103,7 @@ export async function createPaseoDaemon( ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); const connectionSessionId = randomUUID(); + const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); let relayTransport: RelayTransportController | null = null; const agentMcpRoute = config.agentMcpRoute; @@ -442,6 +444,7 @@ export async function createPaseoDaemon( const offer = await createConnectionOfferV1({ sessionId: connectionSessionId, endpoints, + daemonPublicKeyB64: daemonKeyPair.publicKeyB64, }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); @@ -455,6 +458,7 @@ export async function createPaseoDaemon( attachSocket: (ws) => wsServer.attachExternalSocket(ws), relayEndpoint, sessionId: connectionSessionId, + daemonKeyPair: daemonKeyPair.keyPair, }); } } else { diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index 41b03c57d..77fb80f00 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -1,5 +1,4 @@ import os from "node:os"; -import { webcrypto } from "node:crypto"; import { z } from "zod"; export const ConnectionOfferV1Schema = z.object({ @@ -52,14 +51,13 @@ export function buildOfferEndpoints({ export async function createConnectionOfferV1(args: { sessionId: string; endpoints: string[]; + daemonPublicKeyB64: string; }): Promise { - const daemonPublicKeyB64 = await generateDaemonPublicKeyB64(); - return ConnectionOfferV1Schema.parse({ v: 1, sessionId: args.sessionId, endpoints: args.endpoints, - daemonPublicKeyB64, + daemonPublicKeyB64: args.daemonPublicKeyB64, }); } @@ -90,17 +88,6 @@ function getPrimaryLanIp(): string | null { return null; } -async function generateDaemonPublicKeyB64(): Promise { - const keyPair = await webcrypto.subtle.generateKey( - { name: "ECDH", namedCurve: "P-256" }, - true, - ["deriveBits"] - ); - - const raw = await webcrypto.subtle.exportKey("raw", keyPair.publicKey); - return Buffer.from(new Uint8Array(raw)).toString("base64"); -} - function dedupePreserveOrder(values: string[]): string[] { const out: string[] = []; const seen = new Set(); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index cb68bba9b..cd54e78cf 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -5,6 +5,7 @@ import { Writable } from "node:stream"; import net from "node:net"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { createClientChannel, type Transport } from "@paseo/relay"; import { createRelayServer } from "@paseo/relay/node"; function createCapturingLogger() { @@ -34,7 +35,10 @@ function parseOfferUrlFromLogs(lines: string[]): string { throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); } -function decodeOfferFromFragmentUrl(url: string): { sessionId: string } { +function decodeOfferFromFragmentUrl(url: string): { + sessionId: string; + daemonPublicKeyB64: string; +} { const marker = "#offer="; const idx = url.indexOf(marker); if (idx === -1) { @@ -42,9 +46,10 @@ function decodeOfferFromFragmentUrl(url: string): { sessionId: string } { } const encoded = url.slice(idx + marker.length); const json = Buffer.from(encoded, "base64url").toString("utf8"); - const offer = JSON.parse(json) as { sessionId?: string }; + const offer = JSON.parse(json) as { sessionId?: string; daemonPublicKeyB64?: string }; if (!offer.sessionId) throw new Error("offer.sessionId missing"); - return { sessionId: offer.sessionId }; + if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing"); + return { sessionId: offer.sessionId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } async function getAvailablePort(): Promise { @@ -62,7 +67,7 @@ async function getAvailablePort(): Promise { }); } -describe("Relay transport (plaintext) - daemon E2E", () => { +describe("Relay transport (E2EE) - daemon E2E", () => { test( "daemon connects to relay and client ping/pong works through relay", async () => { @@ -82,7 +87,7 @@ describe("Relay transport (plaintext) - daemon E2E", () => { try { const offerUrl = parseOfferUrlFromLogs(lines); - const { sessionId } = decodeOfferFromFragmentUrl(offerUrl); + const { sessionId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); const ws = new WebSocket( `ws://127.0.0.1:${relayPort}/ws?session=${encodeURIComponent( @@ -96,24 +101,53 @@ describe("Relay transport (plaintext) - daemon E2E", () => { reject(new Error("timed out waiting for pong")); }, 20000); - ws.on("open", () => { - ws.send(JSON.stringify({ type: "ping" })); - }); + const transport: Transport = { + send: (data) => ws.send(data), + close: (code?: number, reason?: string) => ws.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; ws.on("message", (data) => { - clearTimeout(timeout); - try { - resolve(JSON.parse(data.toString())); - } catch (err) { - reject(err); - } finally { - ws.close(); - } + transport.onmessage?.(typeof data === "string" ? data : data.toString()); + }); + ws.on("close", (code, reason) => { + transport.onclose?.(code, reason.toString()); + }); + ws.on("error", (err) => { + transport.onerror?.(err); }); - ws.on("error", (err) => { - clearTimeout(timeout); - reject(err); + ws.on("open", async () => { + try { + const channel = await createClientChannel( + transport, + daemonPublicKeyB64, + { + onmessage: (data) => { + clearTimeout(timeout); + try { + const payload = + typeof data === "string" ? JSON.parse(data) : data; + resolve(payload); + } catch (err) { + reject(err); + } finally { + ws.close(); + } + }, + onerror: (err) => { + clearTimeout(timeout); + reject(err); + }, + } + ); + await channel.send(JSON.stringify({ type: "ping" })); + } catch (err) { + clearTimeout(timeout); + reject(err); + } }); }); diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts new file mode 100644 index 000000000..3d2a4b9af --- /dev/null +++ b/packages/server/src/server/daemon-keypair.ts @@ -0,0 +1,78 @@ +/// +import { webcrypto } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +const KeyPairSchema = z.object({ + v: z.literal(1), + publicKeyJwk: z.record(z.any()), + privateKeyJwk: z.record(z.any()), +}); + +type StoredKeyPair = z.infer; + +type LoggerLike = { + child(bindings: Record): LoggerLike; + info(...args: any[]): void; + warn(...args: any[]): void; +}; + +const KEYPAIR_FILENAME = "daemon-keypair.json"; +const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" }; + +export type DaemonKeyPairBundle = { + keyPair: CryptoKeyPair; + publicKeyB64: string; +}; + +export async function loadOrCreateDaemonKeyPair( + paseoHome: string, + logger?: LoggerLike +): Promise { + const log = logger?.child({ module: "daemon-keypair" }); + const filePath = path.join(paseoHome, KEYPAIR_FILENAME); + + if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, "utf8"); + const parsed = KeyPairSchema.parse(JSON.parse(raw)) as StoredKeyPair; + const [publicKey, privateKey] = await Promise.all([ + webcrypto.subtle.importKey("jwk", parsed.publicKeyJwk, ECDH_ALGORITHM, true, []), + webcrypto.subtle.importKey("jwk", parsed.privateKeyJwk, ECDH_ALGORITHM, true, [ + "deriveBits", + ]), + ]); + const publicKeyB64 = await exportPublicKeyB64(publicKey); + log?.info({ filePath }, "Loaded daemon keypair"); + return { keyPair: { publicKey, privateKey }, publicKeyB64 }; + } catch (error) { + log?.warn({ err: error, filePath }, "Failed to load daemon keypair, regenerating"); + } + } + + const keyPair = (await webcrypto.subtle.generateKey(ECDH_ALGORITHM, true, [ + "deriveBits", + ])) as CryptoKeyPair; + const [publicKeyJwk, privateKeyJwk, publicKeyB64] = await Promise.all([ + webcrypto.subtle.exportKey("jwk", keyPair.publicKey), + webcrypto.subtle.exportKey("jwk", keyPair.privateKey), + exportPublicKeyB64(keyPair.publicKey), + ]); + + const payload: StoredKeyPair = { + v: 1, + publicKeyJwk: publicKeyJwk as Record, + privateKeyJwk: privateKeyJwk as Record, + }; + + writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n"); + log?.info({ filePath }, "Saved daemon keypair"); + + return { keyPair, publicKeyB64 }; +} + +async function exportPublicKeyB64(publicKey: CryptoKey): Promise { + const raw = await webcrypto.subtle.exportKey("raw", publicKey); + return Buffer.from(new Uint8Array(raw)).toString("base64"); +} diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index eb3c684c7..984b7a2f0 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,22 +1,39 @@ +/// +import { EventEmitter } from "node:events"; import WebSocket from "ws"; import type pino from "pino"; +import { + createDaemonChannel, + type EncryptedChannel, + type Transport as RelayTransport, +} from "@paseo/relay"; type RelayTransportOptions = { logger: pino.Logger; - attachSocket: (ws: WebSocket) => Promise; + attachSocket: (ws: RelaySocketLike) => Promise; relayEndpoint: string; // "host:port" sessionId: string; + daemonKeyPair?: CryptoKeyPair; }; export type RelayTransportController = { stop: () => Promise; }; +type RelaySocketLike = { + readyState: number; + send: (data: string) => void; + close: (code?: number, reason?: string) => void; + on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void; + once: (event: "close" | "error", listener: (...args: any[]) => void) => void; +}; + export function startRelayTransport({ logger, attachSocket, relayEndpoint, sessionId, + daemonKeyPair, }: RelayTransportOptions): RelayTransportController { const relayLogger = logger.child({ module: "relay-transport" }); @@ -56,7 +73,11 @@ export function startRelayTransport({ if (attached) return; attached = true; - void attachSocket(socket); + if (daemonKeyPair) { + void attachEncryptedSocket(socket, daemonKeyPair, relayLogger, attachSocket); + } else { + void attachSocket(socket); + } }); socket.on("close", (code, reason) => { @@ -90,6 +111,113 @@ export function startRelayTransport({ return { stop }; } +async function attachEncryptedSocket( + socket: WebSocket, + daemonKeyPair: CryptoKeyPair, + logger: pino.Logger, + attachSocket: (ws: RelaySocketLike) => Promise +): Promise { + try { + const relayTransport = createRelayTransportAdapter(socket); + const emitter = new EventEmitter(); + const channel = await createDaemonChannel(relayTransport, daemonKeyPair, { + onmessage: (data) => emitter.emit("message", data), + onclose: (code, reason) => emitter.emit("close", code, reason), + onerror: (error) => { + logger.warn({ err: error }, "relay_e2ee_error"); + emitter.emit("error", error); + }, + }); + const encryptedSocket = createEncryptedSocket(channel, emitter); + await attachSocket(encryptedSocket); + } catch (error) { + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + try { + socket.close(1011, "E2EE handshake failed"); + } catch { + // ignore + } + } +} + +function createRelayTransportAdapter(socket: WebSocket): RelayTransport { + const relayTransport: RelayTransport = { + send: (data) => socket.send(data), + close: (code?: number, reason?: string) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + socket.on("message", (data) => { + relayTransport.onmessage?.(normalizeMessageData(data)); + }); + socket.on("close", (code, reason) => { + relayTransport.onclose?.(code, reason.toString()); + }); + socket.on("error", (err) => { + relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + + return relayTransport; +} + +function createEncryptedSocket( + channel: EncryptedChannel, + emitter: EventEmitter +): RelaySocketLike { + let readyState = 1; + + channel.setState("open"); + + const close = (code?: number, reason?: string) => { + if (readyState === 3) return; + readyState = 3; + channel.close(code, reason); + }; + + emitter.on("close", () => { + if (readyState === 3) return; + readyState = 3; + }); + + return { + get readyState() { + return readyState; + }, + send: (data) => { + void channel.send(data).catch((error) => { + emitter.emit("error", error); + }); + }, + close, + on: (event, listener) => { + emitter.on(event, listener); + }, + once: (event, listener) => { + emitter.once(event, listener); + }, + }; +} + +function normalizeMessageData(data: unknown): string | ArrayBuffer { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return data; + if (ArrayBuffer.isView(data)) { + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + if (Buffer.isBuffer(data)) { + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + return String(data); +} + function buildRelayWebSocketUrl( relayEndpoint: string, sessionId: string, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 002501b91..011984dd3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -86,7 +86,9 @@ export class VoiceAssistantWebSocketServer { this.bridge.broadcast(message); } - public async attachExternalSocket(ws: Parameters[0]): Promise { + public async attachExternalSocket( + ws: Parameters[0] + ): Promise { const fakeRequest = { url: "/ws", headers: {}, diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index b6e7cbc58..9a70e1e66 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -1,5 +1,4 @@ import type { IncomingMessage } from "http"; -import type { WebSocket } from "ws"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { join } from "path"; import { @@ -22,9 +21,17 @@ import type pino from "pino"; export type AgentMcpTransportFactory = () => Promise; +type WebSocketLike = { + readyState: number; + send: (data: string) => void; + close: (code?: number, reason?: string) => void; + on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void; + once: (event: "close" | "error", listener: (...args: any[]) => void) => void; +}; + export class WebSocketSessionBridge { private readonly logger: pino.Logger; - private readonly sessions: Map = new Map(); + private readonly sessions: Map = new Map(); private clientIdCounter = 0; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; @@ -86,7 +93,7 @@ export class WebSocketSessionBridge { return this.sessions.size; } - public async attach(ws: WebSocket, _request: IncomingMessage): Promise { + public async attach(ws: WebSocketLike, _request: IncomingMessage): Promise { const clientId = `client-${++this.clientIdCounter}`; const connectionLogger = this.logger.child({ clientId }); @@ -131,7 +138,7 @@ export class WebSocketSessionBridge { }); } - private async detach(ws: WebSocket, connectionLogger: pino.Logger, clientId: string): Promise { + private async detach(ws: WebSocketLike, connectionLogger: pino.Logger, clientId: string): Promise { const session = this.sessions.get(ws); if (!session) return; @@ -144,7 +151,10 @@ export class WebSocketSessionBridge { this.sessions.delete(ws); } - private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise { + private async handleRawMessage( + ws: WebSocketLike, + data: Buffer | ArrayBuffer | Buffer[] | string + ): Promise { try { const parsed = JSON.parse(data.toString()); const message = WSInboundMessageSchema.parse(parsed); @@ -237,7 +247,7 @@ export class WebSocketSessionBridge { } } - private sendToClient(ws: WebSocket, message: WSOutboundMessage): void { + private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { // WebSocket.OPEN = 1 if (ws.readyState === 1) { ws.send(JSON.stringify(message)); @@ -366,7 +376,7 @@ export class WebSocketSessionBridge { reason: "finished" | "error" | "permission"; }): void { const clientEntries: Array<{ - ws: WebSocket; + ws: WebSocketLike; state: { deviceType: "web" | "mobile" | null; focusedAgentId: string | null; diff --git a/packages/server/tsconfig.server.json b/packages/server/tsconfig.server.json index 8aac9e25f..5d0db8ed6 100644 --- a/packages/server/tsconfig.server.json +++ b/packages/server/tsconfig.server.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2020"], + "lib": ["ES2020", "DOM"], "baseUrl": "./src", "paths": { "@server/*": ["./*"] From 4fb93d92ed42ba59bf69cc29f35101b92abf7017 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 13:57:08 +0700 Subject: [PATCH 2/7] fix: stabilize relay E2EE handshake --- CLAUDE.md | 8 +++ packages/relay/src/encrypted-channel.ts | 21 ++++++-- packages/relay/src/node-adapter.ts | 45 ++++++++++++---- .../daemon-e2e/relay-transport.e2e.test.ts | 6 +++ packages/server/src/server/daemon-keypair.ts | 2 +- packages/server/src/server/relay-transport.ts | 53 ++++++++++++++----- .../src/server/websocket-session-bridge.ts | 24 ++++++--- 7 files changed, 125 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 21285a096..21bbe4d0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,14 @@ Get the session ID from the agent JSON file (`persistence.sessionId`), then: Take screenshots like this: `adb exec-out screencap -p > screenshot.png` +## Testing with Playwright MCP + +**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. + +Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. + +**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. + ## Expo troubleshooting Run `npx expo-doctor` to diagnose version mismatches and native module issues. diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 278364795..23ac7a7b5 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -87,19 +87,28 @@ export async function createDaemonChannel( reject(new Error("Handshake timeout")); }, 10000); + const bufferedMessages: Array = []; + transport.onmessage = async (data) => { try { - if (typeof data !== "string") { - throw new Error("Expected string hello message"); - } + const helloText = + typeof data === "string" ? data : new TextDecoder().decode(data); - const msg = JSON.parse(data) as HelloMessage; + const msg = JSON.parse(helloText) as HelloMessage; if (msg.type !== "hello" || !msg.key) { throw new Error("Invalid hello message"); } clearTimeout(timeout); + // Buffer any subsequent messages that arrive while we're doing async + // WebCrypto work to derive the shared key. Without this, it's possible + // for the next message (already encrypted) to be misinterpreted as a + // second hello, causing the handshake to fail. + transport.onmessage = (next) => { + bufferedMessages.push(next); + }; + const clientPublicKey = await importPublicKey(msg.key); const sharedKey = await deriveSharedKey( daemonKeyPair.privateKey, @@ -110,6 +119,10 @@ export async function createDaemonChannel( channel.setState("open"); events.onopen?.(); + for (const buffered of bufferedMessages) { + transport.onmessage?.(buffered); + } + resolve(channel); } catch (error) { clearTimeout(timeout); diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts index f20b5e489..0cf63b768 100644 --- a/packages/relay/src/node-adapter.ts +++ b/packages/relay/src/node-adapter.ts @@ -72,15 +72,36 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer { const sessionId = url.searchParams.get("session")!; const role = url.searchParams.get("role") as ConnectionRole; - const connection = wrapWebSocket(ws); + const connection = wrapWebSocket(ws, role); relay.addConnection(sessionId, role, connection); - ws.on("message", (data) => { - const message = - data instanceof Buffer - ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) - : String(data); - relay.forward(sessionId, role, message as string | ArrayBuffer); + ws.on("message", (data, isBinary) => { + if (isBinary) { + const message = + data instanceof ArrayBuffer + ? data + : bufferToArrayBuffer( + data instanceof Buffer + ? data + : ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(String(data), "utf8") + ); + relay.forward(sessionId, role, message); + return; + } + + const text = + typeof data === "string" + ? data + : data instanceof Buffer + ? data.toString("utf8") + : data instanceof ArrayBuffer + ? Buffer.from(data).toString("utf8") + : ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8") + : String(data); + relay.forward(sessionId, role, text); }); ws.on("close", () => { @@ -120,9 +141,9 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer { }; } -function wrapWebSocket(ws: NodeWebSocket): RelayConnection { +function wrapWebSocket(ws: NodeWebSocket, role: ConnectionRole): RelayConnection { return { - role: "server", + role, send: (data) => { if (ws.readyState === NodeWebSocket.OPEN) { ws.send(data); @@ -133,3 +154,9 @@ function wrapWebSocket(ws: NodeWebSocket): RelayConnection { }, }; } + +function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + const out = new Uint8Array(buffer.byteLength); + out.set(buffer); + return out.buffer; +} diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index cd54e78cf..731ecc488 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -152,6 +152,12 @@ describe("Relay transport (E2EE) - daemon E2E", () => { }); expect(received).toEqual({ type: "pong" }); + } catch (err) { + const tail = lines.slice(-50).join(""); + // Only prints on failure to help diagnose relay handshake issues. + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", tail); + throw err; } finally { await daemon.close(); await relay.stop(); diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts index 3d2a4b9af..a1a5c58c3 100644 --- a/packages/server/src/server/daemon-keypair.ts +++ b/packages/server/src/server/daemon-keypair.ts @@ -66,7 +66,7 @@ export async function loadOrCreateDaemonKeyPair( privateKeyJwk: privateKeyJwk as Record, }; - writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n"); + writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 }); log?.info({ filePath }, "Saved daemon keypair"); return { keyPair, publicKeyB64 }; diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 984b7a2f0..7a0012c46 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -149,8 +149,8 @@ function createRelayTransportAdapter(socket: WebSocket): RelayTransport { onerror: null, }; - socket.on("message", (data) => { - relayTransport.onmessage?.(normalizeMessageData(data)); + socket.on("message", (data, isBinary) => { + relayTransport.onmessage?.(normalizeMessageData(data, isBinary)); }); socket.on("close", (code, reason) => { relayTransport.onclose?.(code, reason.toString()); @@ -200,24 +200,53 @@ function createEncryptedSocket( }; } -function normalizeMessageData(data: unknown): string | ArrayBuffer { - if (typeof data === "string") return data; +function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (!isBinary) { + if (typeof data === "string") return data; + const buffer = bufferFromWsData(data); + if (buffer) return buffer.toString("utf8"); + return String(data); + } + if (data instanceof ArrayBuffer) return data; - if (ArrayBuffer.isView(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - if (Buffer.isBuffer(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + const buffer = bufferFromWsData(data); + if (buffer) { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); const out = new Uint8Array(view.byteLength); out.set(view); return out.buffer; } + return String(data); } +function bufferFromWsData(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) { + const buffers: Buffer[] = []; + for (const part of data) { + if (Buffer.isBuffer(part)) { + buffers.push(part); + } else if (part instanceof ArrayBuffer) { + buffers.push(Buffer.from(part)); + } else if (ArrayBuffer.isView(part)) { + buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); + } else if (typeof part === "string") { + buffers.push(Buffer.from(part, "utf8")); + } else { + return null; + } + } + return Buffer.concat(buffers); + } + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} + function buildRelayWebSocketUrl( relayEndpoint: string, sessionId: string, diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index 9a70e1e66..e4757d682 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -21,6 +21,19 @@ import type pino from "pino"; export type AgentMcpTransportFactory = () => Promise; +function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer { + if (typeof data === "string") return Buffer.from(data, "utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((item) => + Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer) + ) + ); + } + if (Buffer.isBuffer(data)) return data; + return Buffer.from(data as ArrayBuffer); +} + type WebSocketLike = { readyState: number; send: (data: string) => void; @@ -156,7 +169,8 @@ export class WebSocketSessionBridge { data: Buffer | ArrayBuffer | Buffer[] | string ): Promise { try { - const parsed = JSON.parse(data.toString()); + const buffer = bufferFromWsData(data); + const parsed = JSON.parse(buffer.toString()); const message = WSInboundMessageSchema.parse(parsed); const messageSummary = { @@ -203,13 +217,7 @@ export class WebSocketSessionBridge { let parsedPayload: unknown = null; try { - const buffer = Array.isArray(data) - ? Buffer.concat( - data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer))) - ) - : Buffer.isBuffer(data) - ? data - : Buffer.from(data as ArrayBuffer); + const buffer = bufferFromWsData(data); rawPayload = buffer.toString(); parsedPayload = JSON.parse(rawPayload); } catch (payloadError) { From d68596bcbef5eef80a3aa3967fe6550b56e39996 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:13:03 +0700 Subject: [PATCH 3/7] refactor: centralize relay vs direct E2EE --- .../components/multi-daemon-session-host.tsx | 11 +++--- .../src/contexts/daemon-registry-context.tsx | 25 +++++++------- packages/app/src/hooks/use-daemon-client.ts | 20 +++-------- packages/relay/package.json | 1 + packages/relay/src/e2ee.ts | 7 ++++ .../server/src/client/daemon-client-v2.ts | 34 +++++++++++++------ packages/server/src/server/bootstrap.ts | 3 +- .../server/src/server/connection-offer.ts | 23 ++++--------- .../daemon-e2e/connection-offer.e2e.test.ts | 6 +++- .../daemon-e2e/relay-transport.e2e.test.ts | 2 +- packages/server/src/server/relay-transport.ts | 2 +- .../server/src/shared/connection-offer.ts | 17 ++++++++++ 12 files changed, 83 insertions(+), 68 deletions(-) create mode 100644 packages/relay/src/e2ee.ts create mode 100644 packages/server/src/shared/connection-offer.ts diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index 2aeb3a60d..a5e97a625 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -22,11 +22,11 @@ function buildCandidateUrls(daemon: HostProfile): string[] { }; const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint; - const directEndpoints = relayEndpoint - ? endpoints.filter((endpoint) => endpoint !== relayEndpoint) - : endpoints; + const directEndpoints = endpoints; - if (lastKnownGood && !isLastKnownRelay) { + if (sessionId && relayEndpoint && isLastKnownRelay) { + push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); + } else if (lastKnownGood) { push(buildDaemonWebSocketUrl(lastKnownGood)); } @@ -35,9 +35,6 @@ function buildCandidateUrls(daemon: HostProfile): string[] { } if (sessionId && relayEndpoint) { - if (isLastKnownRelay) { - push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); - } push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId })); } diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index d26e1d09a..e4db52d0c 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react"; import type { ReactNode } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; import { buildDaemonWebSocketUrl, decodeOfferFragmentPayload, @@ -10,6 +9,10 @@ import { extractHostPortFromWebSocketUrl, normalizeHostPort, } from "@/utils/daemon-endpoints"; +import { + ConnectionOfferV1Schema, + type ConnectionOfferV1, +} from "@server/shared/connection-offer"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -41,15 +44,6 @@ type CreateHostInput = { type UpdateHostInput = Partial>; -const ConnectionOfferV1Schema = z.object({ - v: z.literal(1), - sessionId: z.string().min(1), - endpoints: z.array(z.string().min(1)).min(1), - daemonPublicKeyB64: z.string().min(1), -}); - -export type ConnectionOfferV1 = z.infer; - interface DaemonRegistryContextValue { daemons: HostProfile[]; isLoading: boolean; @@ -134,7 +128,12 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const existing = readDaemons(); const now = new Date().toISOString(); const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint)); - const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1]; + const relayEndpoint = + offer.relay?.endpoint + ? normalizeHostPort(offer.relay.endpoint) + : offer.relay === undefined && normalizedEndpoints.length > 0 + ? normalizedEndpoints[normalizedEndpoints.length - 1] + : null; const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64); if (matchIndex !== -1) { @@ -142,7 +141,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { ...existing[matchIndex], daemonPublicKeyB64: offer.daemonPublicKeyB64, endpoints: normalizedEndpoints, - relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, updatedAt: now, }; const next = [...existing]; @@ -156,7 +155,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"), endpoints: normalizedEndpoints, daemonPublicKeyB64: offer.daemonPublicKeyB64, - relay: { endpoint: relayEndpoint, sessionId: offer.sessionId }, + relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, createdAt: now, updatedAt: now, metadata: null, diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts index f3ac56f45..fb5d26568 100644 --- a/packages/app/src/hooks/use-daemon-client.ts +++ b/packages/app/src/hooks/use-daemon-client.ts @@ -13,15 +13,6 @@ type DaemonClientOptions = { daemonPublicKeyB64?: string; }; -function isRelayWebSocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("session"); - } catch { - return false; - } -} - export function useDaemonClient( url: string, options: DaemonClientOptions = {} @@ -29,21 +20,18 @@ export function useDaemonClient( const client = useMemo( () => { const tauriTransportFactory = createTauriWebSocketTransportFactory(); - const relayConnection = isRelayWebSocketUrl(url); return new DaemonClientV2({ url, suppressSendErrors: true, ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), - ...(relayConnection + e2ee: options.daemonPublicKeyB64 ? { - e2ee: { - enabled: true, - daemonPublicKeyB64: options.daemonPublicKeyB64, - }, + enabled: true, + daemonPublicKeyB64: options.daemonPublicKeyB64, } - : {}), + : undefined, }); }, [options.daemonPublicKeyB64, url] diff --git a/packages/relay/package.json b/packages/relay/package.json index b0973d931..a6b1dfc18 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./e2ee": "./src/e2ee.ts", "./node": "./src/node-adapter.ts", "./cloudflare": "./src/cloudflare-adapter.ts" }, diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts new file mode 100644 index 000000000..a26e97fab --- /dev/null +++ b/packages/relay/src/e2ee.ts @@ -0,0 +1,7 @@ +export { + createClientChannel, + createDaemonChannel, + EncryptedChannel, +} from "./encrypted-channel.js"; +export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; + diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index a9ab4f5d8..fab6edbd2 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -57,7 +57,7 @@ import { createClientChannel, type EncryptedChannel, type Transport as RelayTransport, -} from "@paseo/relay"; +} from "@paseo/relay/e2ee"; export interface Logger { debug(obj: object, msg?: string): void; @@ -319,14 +319,16 @@ export class DaemonClientV2 { createWebSocketTransportFactory( this.config.webSocketFactory ?? defaultWebSocketFactory ); - const transportFactory = - this.config.e2ee?.enabled === true - ? createEncryptedTransportFactory( - baseTransportFactory, - this.config.e2ee?.daemonPublicKeyB64, - this.logger - ) - : baseTransportFactory; + const shouldUseRelayE2ee = + this.config.e2ee?.enabled === true && + isRelayClientWebSocketUrl(this.config.url); + const transportFactory = shouldUseRelayE2ee + ? createRelayE2eeTransportFactory( + baseTransportFactory, + this.config.e2ee?.daemonPublicKeyB64, + this.logger + ) + : baseTransportFactory; const transport = transportFactory({ url: this.config.url, headers }); this.transport = transport; @@ -2342,7 +2344,7 @@ function createWebSocketTransportFactory( }; } -function createEncryptedTransportFactory( +function createRelayE2eeTransportFactory( baseFactory: DaemonTransportFactory, daemonPublicKeyB64: string | undefined, logger: Logger @@ -2357,6 +2359,18 @@ function createEncryptedTransportFactory( }; } +function isRelayClientWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.searchParams.get("role") === "client" && + parsed.searchParams.has("session") + ); + } catch { + return false; + } +} + function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8bffecd58..9e94c7202 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -437,14 +437,13 @@ export async function createPaseoDaemon( const endpoints = buildOfferEndpoints({ listenHost: listenTarget.host, port: listenTarget.port, - relayEnabled, - relayEndpoint, }); const offer = await createConnectionOfferV1({ sessionId: connectionSessionId, endpoints, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, + relay: relayEnabled ? { endpoint: relayEndpoint } : null, }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index 77fb80f00..540b1b76e 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -1,27 +1,18 @@ import os from "node:os"; -import { z } from "zod"; -export const ConnectionOfferV1Schema = z.object({ - v: z.literal(1), - sessionId: z.string().min(1), - endpoints: z.array(z.string().min(1)).min(1), - daemonPublicKeyB64: z.string().min(1), -}); - -export type ConnectionOfferV1 = z.infer; +import { + ConnectionOfferV1Schema, + type ConnectionOfferV1, +} from "../shared/connection-offer.js"; type BuildOfferEndpointsArgs = { listenHost: string; port: number; - relayEnabled: boolean; - relayEndpoint: string; }; export function buildOfferEndpoints({ listenHost, port, - relayEnabled, - relayEndpoint, }: BuildOfferEndpointsArgs): string[] { const endpoints: string[] = []; @@ -41,10 +32,6 @@ export function buildOfferEndpoints({ endpoints.push(`localhost:${port}`); endpoints.push(`127.0.0.1:${port}`); - if (relayEnabled) { - endpoints.push(relayEndpoint); - } - return dedupePreserveOrder(endpoints); } @@ -52,12 +39,14 @@ export async function createConnectionOfferV1(args: { sessionId: string; endpoints: string[]; daemonPublicKeyB64: string; + relay?: { endpoint: string } | null; }): Promise { return ConnectionOfferV1Schema.parse({ v: 1, sessionId: args.sessionId, endpoints: args.endpoints, daemonPublicKeyB64: args.daemonPublicKeyB64, + relay: args.relay ?? null, }); } diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 82f9617c5..d0ddc9388 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -89,6 +89,7 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { sessionId: string; endpoints: string[]; daemonPublicKeyB64: string; + relay?: { endpoint: string } | null; }; expect(offer.v).toBe(1); @@ -97,7 +98,8 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { expect(Array.isArray(offer.endpoints)).toBe(true); expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`); expect(offer.endpoints).toContain(`localhost:${daemon.port}`); - expect(offer.endpoints).toContain("relay.paseo.sh:443"); + expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); + expect(offer.relay?.endpoint).toBe("relay.paseo.sh:443"); expect(typeof offer.daemonPublicKeyB64).toBe("string"); expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0); expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow(); @@ -167,9 +169,11 @@ describe("ConnectionOfferV1 (daemon E2E)", () => { const offer = decodeOfferFromFragmentUrl(offerUrl) as { endpoints: string[]; + relay?: { endpoint: string } | null; }; expect(offer.endpoints).not.toContain("relay.paseo.sh:443"); + expect(offer.relay).toBe(null); expect(offer.endpoints).toContain(`localhost:${port}`); expect(offer.endpoints).toContain(`192.168.1.12:${port}`); } catch (err) { diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 731ecc488..30837c251 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -5,7 +5,7 @@ import { Writable } from "node:stream"; import net from "node:net"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { createClientChannel, type Transport } from "@paseo/relay"; +import { createClientChannel, type Transport } from "@paseo/relay/e2ee"; import { createRelayServer } from "@paseo/relay/node"; function createCapturingLogger() { diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 7a0012c46..3a33d8219 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -6,7 +6,7 @@ import { createDaemonChannel, type EncryptedChannel, type Transport as RelayTransport, -} from "@paseo/relay"; +} from "@paseo/relay/e2ee"; type RelayTransportOptions = { logger: pino.Logger; diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts new file mode 100644 index 000000000..37be5c9bb --- /dev/null +++ b/packages/server/src/shared/connection-offer.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const ConnectionOfferV1Schema = z.object({ + v: z.literal(1), + sessionId: z.string().min(1), + endpoints: z.array(z.string().min(1)).min(1), + daemonPublicKeyB64: z.string().min(1), + relay: z + .object({ + endpoint: z.string().min(1), + }) + .nullable() + .optional(), +}); + +export type ConnectionOfferV1 = z.infer; + From a3a12b54de73457a22d4dc8152316b10e492b98a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:19:12 +0700 Subject: [PATCH 4/7] refactor: share ws endpoint utilities --- .../components/multi-daemon-session-host.tsx | 26 ++-- packages/app/src/utils/daemon-endpoints.ts | 115 +++-------------- .../server/src/client/daemon-client-v2.ts | 13 +- packages/server/src/server/relay-transport.ts | 51 ++------ .../server/src/shared/connection-offer.ts | 1 - .../server/src/shared/daemon-endpoints.ts | 117 ++++++++++++++++++ 6 files changed, 153 insertions(+), 170 deletions(-) create mode 100644 packages/server/src/shared/daemon-endpoints.ts diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index a5e97a625..a6b2d9383 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -4,7 +4,7 @@ import { type HostProfile, } from "@/contexts/daemon-registry-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; +import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints"; import { useEffect, useMemo, useRef, useState } from "react"; function buildCandidateUrls(daemon: HostProfile): string[] { @@ -41,18 +41,6 @@ function buildCandidateUrls(daemon: HostProfile): string[] { return out; } -function extractEndpointFromUrl(url: string): string | null { - try { - const parsed = new URL(url); - const host = parsed.hostname; - const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; - const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); - return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; - } catch { - return null; - } -} - function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { const { connectionStates } = useDaemonConnections(); const { updateDaemon } = useDaemonRegistry(); @@ -87,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { useEffect(() => { if (!connection) return; - if (status === "online") { - if (pendingMetadataWriteRef.current) return; - const endpoint = extractEndpointFromUrl(activeUrl); + if (status === "online") { + if (pendingMetadataWriteRef.current) return; + const endpoint = (() => { + try { + return extractHostPortFromWebSocketUrl(activeUrl); + } catch { + return null; + } + })(); if (!endpoint) return; if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return; diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index 34f134b40..f3885b5b8 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -1,9 +1,22 @@ import { Buffer } from "buffer"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl, + deriveLabelFromEndpoint, + extractHostPortFromWebSocketUrl, + normalizeHostPort, + parseHostPort, + type HostPortParts, +} from "@server/shared/daemon-endpoints"; -export type HostPortParts = { - host: string; - port: number; - isIpv6: boolean; +export type { HostPortParts }; + +export { + buildDaemonWebSocketUrl, + deriveLabelFromEndpoint, + extractHostPortFromWebSocketUrl, + normalizeHostPort, + parseHostPort, }; function decodeBase64UrlToUtf8(input: string): string { @@ -17,98 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown { return JSON.parse(json) as unknown; } -export function parseHostPort(input: string): HostPortParts { - const trimmed = input.trim(); - if (!trimmed) { - throw new Error("Host is required"); - } - - // IPv6: [::1]:6767 - if (trimmed.startsWith("[")) { - const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/); - if (!match) { - throw new Error("Invalid host:port (expected [::1]:6767)"); - } - const host = match[1].trim(); - const port = Number(match[2]); - if (!host) throw new Error("Host is required"); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Port must be between 1 and 65535"); - } - return { host, port, isIpv6: true }; - } - - const match = trimmed.match(/^(.+):(\d{1,5})$/); - if (!match) { - throw new Error("Invalid host:port (expected localhost:6767)"); - } - const host = match[1].trim(); - const port = Number(match[2]); - if (!host) throw new Error("Host is required"); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Port must be between 1 and 65535"); - } - return { host, port, isIpv6: false }; -} - -export function normalizeHostPort(input: string): string { - const { host, port, isIpv6 } = parseHostPort(input); - if (isIpv6) { - return `[${host}]:${port}`; - } - return `${host}:${port}`; -} - -export function extractHostPortFromWebSocketUrl(wsUrl: string): string { - const parsed = new URL(wsUrl); - if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { - throw new Error("Invalid WebSocket URL protocol"); - } - if (parsed.pathname.replace(/\/+$/, "") !== "/ws") { - throw new Error("Invalid WebSocket URL (expected /ws path)"); - } - - const host = parsed.hostname; - const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; - if (!host) { - throw new Error("Invalid WebSocket URL (missing hostname)"); - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Invalid WebSocket URL (invalid port)"); - } - - const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); - const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; - return hostPort; -} - -function shouldUseSecureWebSocket(port: number): boolean { - return port === 443; -} - -export function buildDaemonWebSocketUrl(endpoint: string): string { - const { host, port, isIpv6 } = parseHostPort(endpoint); - const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; - const hostPart = isIpv6 ? `[${host}]` : host; - return `${protocol}://${hostPart}:${port}/ws`; -} - export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string { - const { host, port, isIpv6 } = parseHostPort(params.endpoint); - const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; - const hostPart = isIpv6 ? `[${host}]` : host; - const url = new URL(`${protocol}://${hostPart}:${port}/ws`); - url.searchParams.set("session", params.sessionId); - url.searchParams.set("role", "client"); - return url.toString(); + return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); } - -export function deriveLabelFromEndpoint(endpoint: string): string { - try { - const { host } = parseHostPort(endpoint); - return host || "Unnamed Host"; - } catch { - return "Unnamed Host"; - } -} - diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index fab6edbd2..0e82bb815 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -58,6 +58,7 @@ import { type EncryptedChannel, type Transport as RelayTransport, } from "@paseo/relay/e2ee"; +import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; export interface Logger { debug(obj: object, msg?: string): void; @@ -2359,18 +2360,6 @@ function createRelayE2eeTransportFactory( }; } -function isRelayClientWebSocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ( - parsed.searchParams.get("role") === "client" && - parsed.searchParams.has("session") - ); - } catch { - return false; - } -} - function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 3a33d8219..ca8c22093 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -7,6 +7,7 @@ import { type EncryptedChannel, type Transport as RelayTransport, } from "@paseo/relay/e2ee"; +import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; type RelayTransportOptions = { logger: pino.Logger; @@ -61,7 +62,11 @@ export function startRelayTransport({ const connect = (): void => { if (stopped) return; - const url = buildRelayWebSocketUrl(relayEndpoint, sessionId, "server"); + const url = buildRelayWebSocketUrl({ + endpoint: relayEndpoint, + sessionId, + role: "server", + }); const socket = new WebSocket(url); ws = socket; @@ -247,46 +252,4 @@ function bufferFromWsData(data: unknown): Buffer | null { return null; } -function buildRelayWebSocketUrl( - relayEndpoint: string, - sessionId: string, - role: "server" | "client" -): string { - const { host, port } = parseHostPort(relayEndpoint); - const protocol = port === 443 ? "wss" : "ws"; - return `${protocol}://${host}:${port}/ws?session=${encodeURIComponent( - sessionId - )}&role=${role}`; -} - -function parseHostPort(input: string): { host: string; port: number } { - const trimmed = input.trim(); - - if (trimmed.startsWith("[")) { - const endIdx = trimmed.indexOf("]"); - if (endIdx === -1) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - const host = trimmed.slice(1, endIdx); - const rest = trimmed.slice(endIdx + 1); - if (!rest.startsWith(":")) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - const port = Number.parseInt(rest.slice(1), 10); - if (!Number.isFinite(port)) { - throw new Error(`Invalid relay port: ${input}`); - } - return { host: `[${host}]`, port }; - } - - const idx = trimmed.lastIndexOf(":"); - if (idx === -1) { - throw new Error(`Invalid relay endpoint (expected host:port): ${input}`); - } - const host = trimmed.slice(0, idx); - const port = Number.parseInt(trimmed.slice(idx + 1), 10); - if (!host || !Number.isFinite(port)) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - return { host, port }; -} +// buildRelayWebSocketUrl + parseHostPort live in ../shared/daemon-endpoints.ts diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts index 37be5c9bb..a21d3e4ef 100644 --- a/packages/server/src/shared/connection-offer.ts +++ b/packages/server/src/shared/connection-offer.ts @@ -14,4 +14,3 @@ export const ConnectionOfferV1Schema = z.object({ }); export type ConnectionOfferV1 = z.infer; - diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts new file mode 100644 index 000000000..7eb50dbe0 --- /dev/null +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -0,0 +1,117 @@ +export type HostPortParts = { + host: string; + port: number; + isIpv6: boolean; +}; + +export type RelayRole = "server" | "client"; + +function parsePort(portStr: string, context: string): number { + const port = Number(portStr); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${context}: port must be between 1 and 65535`); + } + return port; +} + +export function parseHostPort(input: string): HostPortParts { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Host is required"); + } + + // IPv6: [::1]:6767 + if (trimmed.startsWith("[")) { + const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/); + if (!match) { + throw new Error("Invalid host:port (expected [::1]:6767)"); + } + const host = match[1].trim(); + if (!host) throw new Error("Host is required"); + const port = parsePort(match[2], "Invalid host:port"); + return { host, port, isIpv6: true }; + } + + const match = trimmed.match(/^(.+):(\d{1,5})$/); + if (!match) { + throw new Error("Invalid host:port (expected localhost:6767)"); + } + const host = match[1].trim(); + if (!host) throw new Error("Host is required"); + const port = parsePort(match[2], "Invalid host:port"); + return { host, port, isIpv6: false }; +} + +export function normalizeHostPort(input: string): string { + const { host, port, isIpv6 } = parseHostPort(input); + return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; +} + +export function deriveLabelFromEndpoint(endpoint: string): string { + try { + const { host } = parseHostPort(endpoint); + return host || "Unnamed Host"; + } catch { + return "Unnamed Host"; + } +} + +function shouldUseSecureWebSocket(port: number): boolean { + return port === 443; +} + +export function buildDaemonWebSocketUrl(endpoint: string): string { + const { host, port, isIpv6 } = parseHostPort(endpoint); + const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const hostPart = isIpv6 ? `[${host}]` : host; + return `${protocol}://${hostPart}:${port}/ws`; +} + +export function buildRelayWebSocketUrl(params: { + endpoint: string; + sessionId: string; + role: RelayRole; +}): string { + const { host, port, isIpv6 } = parseHostPort(params.endpoint); + const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const hostPart = isIpv6 ? `[${host}]` : host; + const url = new URL(`${protocol}://${hostPart}:${port}/ws`); + url.searchParams.set("session", params.sessionId); + url.searchParams.set("role", params.role); + return url.toString(); +} + +export function extractHostPortFromWebSocketUrl(wsUrl: string): string { + const parsed = new URL(wsUrl); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new Error("Invalid WebSocket URL protocol"); + } + if (parsed.pathname.replace(/\/+$/, "") !== "/ws") { + throw new Error("Invalid WebSocket URL (expected /ws path)"); + } + + const host = parsed.hostname; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; + if (!host) { + throw new Error("Invalid WebSocket URL (missing hostname)"); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Invalid WebSocket URL (invalid port)"); + } + + const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); + return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; +} + +export function isRelayClientWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.searchParams.get("role") === "client" && + parsed.searchParams.has("session") + ); + } catch { + return false; + } +} + From 434955eb937633e9e90ed7d8949a5aec839fd41f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:21:53 +0700 Subject: [PATCH 5/7] refactor: remove ternary soup and tighten E2EE config --- .../src/contexts/daemon-registry-context.tsx | 20 ++++++---- .../server/src/client/daemon-client-v2.ts | 38 ++++++++++--------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index e4db52d0c..d06918947 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -128,12 +128,16 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { const existing = readDaemons(); const now = new Date().toISOString(); const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint)); - const relayEndpoint = - offer.relay?.endpoint - ? normalizeHostPort(offer.relay.endpoint) - : offer.relay === undefined && normalizedEndpoints.length > 0 - ? normalizedEndpoints[normalizedEndpoints.length - 1] - : null; + let relayEndpoint: string | null = null; + if (offer.relay?.endpoint) { + relayEndpoint = normalizeHostPort(offer.relay.endpoint); + } else if (offer.relay === undefined && normalizedEndpoints.length > 0) { + // Back-compat: older offers encoded relay endpoint as the last entry. + relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1]; + } + const relay = relayEndpoint + ? { endpoint: relayEndpoint, sessionId: offer.sessionId } + : null; const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64); if (matchIndex !== -1) { @@ -141,7 +145,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { ...existing[matchIndex], daemonPublicKeyB64: offer.daemonPublicKeyB64, endpoints: normalizedEndpoints, - relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, + relay, updatedAt: now, }; const next = [...existing]; @@ -155,7 +159,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) { label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"), endpoints: normalizedEndpoints, daemonPublicKeyB64: offer.daemonPublicKeyB64, - relay: relayEndpoint ? { endpoint: relayEndpoint, sessionId: offer.sessionId } : null, + relay, createdAt: now, updatedAt: now, metadata: null, diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 0e82bb815..402d87224 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -323,13 +323,19 @@ export class DaemonClientV2 { const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url); - const transportFactory = shouldUseRelayE2ee - ? createRelayE2eeTransportFactory( - baseTransportFactory, - this.config.e2ee?.daemonPublicKeyB64, - this.logger - ) - : baseTransportFactory; + + let transportFactory = baseTransportFactory; + if (shouldUseRelayE2ee) { + const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; + if (!daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required for relay E2EE"); + } + transportFactory = createRelayE2eeTransportFactory({ + baseFactory: baseTransportFactory, + daemonPublicKeyB64, + logger: this.logger, + }); + } const transport = transportFactory({ url: this.config.url, headers }); this.transport = transport; @@ -2345,18 +2351,14 @@ function createWebSocketTransportFactory( }; } -function createRelayE2eeTransportFactory( - baseFactory: DaemonTransportFactory, - daemonPublicKeyB64: string | undefined, - logger: Logger -): DaemonTransportFactory { - if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required for relay E2EE"); - } - +function createRelayE2eeTransportFactory(args: { + baseFactory: DaemonTransportFactory; + daemonPublicKeyB64: string; + logger: Logger; +}): DaemonTransportFactory { return ({ url, headers }) => { - const base = baseFactory({ url, headers }); - return createEncryptedTransport(base, daemonPublicKeyB64, logger); + const base = args.baseFactory({ url, headers }); + return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); }; } From 6e5cfa1eb4d98d4a99d4e87574f5b281aaa3494e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:24:04 +0700 Subject: [PATCH 6/7] refactor: simplify relay ws message normalization --- packages/relay/src/node-adapter.ts | 70 +++++++++++++++++++----------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts index 0cf63b768..b8d79be74 100644 --- a/packages/relay/src/node-adapter.ts +++ b/packages/relay/src/node-adapter.ts @@ -76,32 +76,7 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer { relay.addConnection(sessionId, role, connection); ws.on("message", (data, isBinary) => { - if (isBinary) { - const message = - data instanceof ArrayBuffer - ? data - : bufferToArrayBuffer( - data instanceof Buffer - ? data - : ArrayBuffer.isView(data) - ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) - : Buffer.from(String(data), "utf8") - ); - relay.forward(sessionId, role, message); - return; - } - - const text = - typeof data === "string" - ? data - : data instanceof Buffer - ? data.toString("utf8") - : data instanceof ArrayBuffer - ? Buffer.from(data).toString("utf8") - : ArrayBuffer.isView(data) - ? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8") - : String(data); - relay.forward(sessionId, role, text); + relay.forward(sessionId, role, normalizeWsMessageForRelay(data, isBinary)); }); ws.on("close", () => { @@ -160,3 +135,46 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { out.set(buffer); return out.buffer; } + +function normalizeWsMessageForRelay(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (isBinary) { + return normalizeWsBinaryMessage(data); + } + return normalizeWsTextMessage(data); +} + +function normalizeWsBinaryMessage(data: unknown): ArrayBuffer { + if (data instanceof ArrayBuffer) { + return data; + } + return bufferToArrayBuffer(bufferFromWsData(data)); +} + +function normalizeWsTextMessage(data: unknown): string { + if (typeof data === "string") { + return data; + } + return bufferFromWsData(data).toString("utf8"); +} + +function bufferFromWsData(data: unknown): Buffer { + if (Buffer.isBuffer(data)) return data; + + if (Array.isArray(data)) { + return Buffer.concat(data.map(bufferFromWsData)); + } + + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + + if (typeof data === "string") { + return Buffer.from(data, "utf8"); + } + + return Buffer.from(String(data), "utf8"); +} From 54bc3a49eca54781c6e84b8153f1aaffba644886 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:32:41 +0700 Subject: [PATCH 7/7] refactor: use pino.Logger in daemon-keypair --- packages/server/src/server/daemon-keypair.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts index a1a5c58c3..bfed06f3d 100644 --- a/packages/server/src/server/daemon-keypair.ts +++ b/packages/server/src/server/daemon-keypair.ts @@ -3,6 +3,7 @@ import { webcrypto } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; +import type pino from "pino"; const KeyPairSchema = z.object({ v: z.literal(1), @@ -12,12 +13,6 @@ const KeyPairSchema = z.object({ type StoredKeyPair = z.infer; -type LoggerLike = { - child(bindings: Record): LoggerLike; - info(...args: any[]): void; - warn(...args: any[]): void; -}; - const KEYPAIR_FILENAME = "daemon-keypair.json"; const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" }; @@ -28,7 +23,7 @@ export type DaemonKeyPairBundle = { export async function loadOrCreateDaemonKeyPair( paseoHome: string, - logger?: LoggerLike + logger?: pino.Logger ): Promise { const log = logger?.child({ module: "daemon-keypair" }); const filePath = path.join(paseoHome, KEYPAIR_FILENAME);