Merge branch 'plan/e2ee' - End-to-end encryption for relay connections
Implements E2EE for relay connections only, keeping direct connections and CLI plaintext. The relay acts as a dumb byte forwarder with no access to decrypted messages. Key changes: - Daemon keypair persistence in $PASEO_HOME/daemon-keypair.json - Encrypted relay transport using ECDH P-256 + AES-256-GCM - Client-side E2EE using daemon's public key from pairing offer - WebCrypto compatibility for React Native
This commit is contained in:
Generated
+387
-2
@@ -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": {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -42,8 +42,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",
|
||||
@@ -86,6 +88,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",
|
||||
|
||||
@@ -4,12 +4,13 @@ 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[] {
|
||||
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,38 +21,26 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
if (!out.includes(url)) out.push(url);
|
||||
};
|
||||
|
||||
if (lastKnownGood) {
|
||||
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
|
||||
const directEndpoints = endpoints;
|
||||
|
||||
if (sessionId && relayEndpoint && isLastKnownRelay) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
} else if (lastKnownGood) {
|
||||
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) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -86,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;
|
||||
|
||||
@@ -123,7 +118,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
|
||||
<SessionProvider
|
||||
key={`${daemon.id}:${activeUrl}`}
|
||||
serverUrl={activeUrl}
|
||||
serverId={daemon.id}
|
||||
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
@@ -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<Omit<HostProfile, "id" | "createdAt">>;
|
||||
|
||||
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<typeof ConnectionOfferV1Schema>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[];
|
||||
isLoading: boolean;
|
||||
@@ -134,7 +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 = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
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) {
|
||||
@@ -142,7 +145,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
...existing[matchIndex],
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
endpoints: normalizedEndpoints,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
relay,
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing];
|
||||
@@ -156,7 +159,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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
|
||||
@@ -257,6 +257,7 @@ interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
daemonPublicKeyB64?: string;
|
||||
}
|
||||
|
||||
// SessionProvider: Daemon client message handler that updates Zustand store
|
||||
@@ -264,8 +265,9 @@ export function SessionProvider({
|
||||
children,
|
||||
serverUrl,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
}: SessionProviderProps) {
|
||||
const client = useDaemonClient(serverUrl);
|
||||
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
|
||||
const [connectionSnapshot, setConnectionSnapshot] =
|
||||
useState<DaemonConnectionSnapshot>(() =>
|
||||
mapConnectionState(client.getConnectionState(), client.lastError)
|
||||
|
||||
@@ -9,7 +9,14 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDaemonClient(url: string): DaemonClient {
|
||||
type DaemonClientOptions = {
|
||||
daemonPublicKeyB64?: string;
|
||||
};
|
||||
|
||||
export function useDaemonClient(
|
||||
url: string,
|
||||
options: DaemonClientOptions = {}
|
||||
): DaemonClient {
|
||||
const client = useMemo(
|
||||
() => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
@@ -19,9 +26,15 @@ export function useDaemonClient(url: string): DaemonClient {
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
e2ee: options.daemonPublicKeyB64
|
||||
? {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: options.daemonPublicKeyB64,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[url]
|
||||
[options.daemonPublicKeyB64, url]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<void>)
|
||||
| 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 = <T extends ArrayBufferView>(array: T): T => {
|
||||
return ExpoCrypto.getRandomValues(array as any) as T;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./e2ee": "./src/e2ee.ts",
|
||||
"./node": "./src/node-adapter.ts",
|
||||
"./cloudflare": "./src/cloudflare-adapter.ts"
|
||||
},
|
||||
@@ -14,6 +15,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference lib="dom" />
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
EncryptedChannel,
|
||||
} from "./encrypted-channel.js";
|
||||
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference lib="dom" />
|
||||
/**
|
||||
* 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;
|
||||
@@ -85,19 +87,28 @@ export async function createDaemonChannel(
|
||||
reject(new Error("Handshake timeout"));
|
||||
}, 10000);
|
||||
|
||||
const bufferedMessages: Array<string | ArrayBuffer> = [];
|
||||
|
||||
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,
|
||||
@@ -108,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);
|
||||
@@ -193,23 +208,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;
|
||||
}
|
||||
|
||||
@@ -72,15 +72,11 @@ 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) => {
|
||||
relay.forward(sessionId, role, normalizeWsMessageForRelay(data, isBinary));
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
@@ -120,9 +116,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 +129,52 @@ function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
const out = new Uint8Array(buffer.byteLength);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ 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/e2ee";
|
||||
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||
|
||||
export interface Logger {
|
||||
debug(obj: object, msg?: string): void;
|
||||
@@ -146,6 +152,10 @@ export type DaemonClientConfig = {
|
||||
transportFactory?: DaemonTransportFactory;
|
||||
webSocketFactory?: WebSocketFactory;
|
||||
logger?: Logger;
|
||||
e2ee?: {
|
||||
enabled?: boolean;
|
||||
daemonPublicKeyB64?: string;
|
||||
};
|
||||
reconnect?: {
|
||||
enabled?: boolean;
|
||||
baseDelayMs?: number;
|
||||
@@ -328,11 +338,27 @@ export class DaemonClient {
|
||||
|
||||
try {
|
||||
this.cleanupTransport();
|
||||
const transportFactory =
|
||||
const baseTransportFactory =
|
||||
this.config.transportFactory ??
|
||||
createWebSocketTransportFactory(
|
||||
this.config.webSocketFactory ?? defaultWebSocketFactory
|
||||
);
|
||||
const shouldUseRelayE2ee =
|
||||
this.config.e2ee?.enabled === true &&
|
||||
isRelayClientWebSocketUrl(this.config.url);
|
||||
|
||||
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;
|
||||
|
||||
@@ -2549,6 +2575,202 @@ function createWebSocketTransportFactory(
|
||||
};
|
||||
}
|
||||
|
||||
function createRelayE2eeTransportFactory(args: {
|
||||
baseFactory: DaemonTransportFactory;
|
||||
daemonPublicKeyB64: string;
|
||||
logger: Logger;
|
||||
}): DaemonTransportFactory {
|
||||
return ({ url, headers }) => {
|
||||
const base = args.baseFactory({ url, headers });
|
||||
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.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",
|
||||
|
||||
@@ -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 {
|
||||
@@ -105,6 +106,7 @@ export async function createPaseoDaemon(
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const connectionSessionId = randomUUID();
|
||||
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
|
||||
let relayTransport: RelayTransportController | null = null;
|
||||
|
||||
const staticDir = config.staticDir;
|
||||
@@ -460,13 +462,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 });
|
||||
@@ -480,6 +482,7 @@ export async function createPaseoDaemon(
|
||||
attachSocket: (ws) => wsServer.attachExternalSocket(ws),
|
||||
relayEndpoint,
|
||||
sessionId: connectionSessionId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import os from "node:os";
|
||||
import { webcrypto } from "node:crypto";
|
||||
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<typeof ConnectionOfferV1Schema>;
|
||||
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[] = [];
|
||||
|
||||
@@ -42,24 +32,21 @@ export function buildOfferEndpoints({
|
||||
endpoints.push(`localhost:${port}`);
|
||||
endpoints.push(`127.0.0.1:${port}`);
|
||||
|
||||
if (relayEnabled) {
|
||||
endpoints.push(relayEndpoint);
|
||||
}
|
||||
|
||||
return dedupePreserveOrder(endpoints);
|
||||
}
|
||||
|
||||
export async function createConnectionOfferV1(args: {
|
||||
sessionId: string;
|
||||
endpoints: string[];
|
||||
daemonPublicKeyB64: string;
|
||||
relay?: { endpoint: string } | null;
|
||||
}): Promise<ConnectionOfferV1> {
|
||||
const daemonPublicKeyB64 = await generateDaemonPublicKeyB64();
|
||||
|
||||
return ConnectionOfferV1Schema.parse({
|
||||
v: 1,
|
||||
sessionId: args.sessionId,
|
||||
endpoints: args.endpoints,
|
||||
daemonPublicKeyB64,
|
||||
daemonPublicKeyB64: args.daemonPublicKeyB64,
|
||||
relay: args.relay ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,17 +77,6 @@ function getPrimaryLanIp(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function generateDaemonPublicKeyB64(): Promise<string> {
|
||||
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<string>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/e2ee";
|
||||
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<number> {
|
||||
@@ -62,7 +67,7 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
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,28 +101,63 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// <reference lib="dom" />
|
||||
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),
|
||||
publicKeyJwk: z.record(z.any()),
|
||||
privateKeyJwk: z.record(z.any()),
|
||||
});
|
||||
|
||||
type StoredKeyPair = z.infer<typeof KeyPairSchema>;
|
||||
|
||||
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?: pino.Logger
|
||||
): Promise<DaemonKeyPairBundle> {
|
||||
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<string, unknown>,
|
||||
privateKeyJwk: privateKeyJwk as Record<string, unknown>,
|
||||
};
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
||||
log?.info({ filePath }, "Saved daemon keypair");
|
||||
|
||||
return { keyPair, publicKeyB64 };
|
||||
}
|
||||
|
||||
async function exportPublicKeyB64(publicKey: CryptoKey): Promise<string> {
|
||||
const raw = await webcrypto.subtle.exportKey("raw", publicKey);
|
||||
return Buffer.from(new Uint8Array(raw)).toString("base64");
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
/// <reference lib="dom" />
|
||||
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/e2ee";
|
||||
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||
|
||||
type RelayTransportOptions = {
|
||||
logger: pino.Logger;
|
||||
attachSocket: (ws: WebSocket) => Promise<void>;
|
||||
attachSocket: (ws: RelaySocketLike) => Promise<void>;
|
||||
relayEndpoint: string; // "host:port"
|
||||
sessionId: string;
|
||||
daemonKeyPair?: CryptoKeyPair;
|
||||
};
|
||||
|
||||
export type RelayTransportController = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
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" });
|
||||
|
||||
@@ -44,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;
|
||||
|
||||
@@ -56,7 +78,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,46 +116,140 @@ export function startRelayTransport({
|
||||
return { stop };
|
||||
}
|
||||
|
||||
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}`;
|
||||
async function attachEncryptedSocket(
|
||||
socket: WebSocket,
|
||||
daemonKeyPair: CryptoKeyPair,
|
||||
logger: pino.Logger,
|
||||
attachSocket: (ws: RelaySocketLike) => Promise<void>
|
||||
): Promise<void> {
|
||||
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 parseHostPort(input: string): { host: string; port: number } {
|
||||
const trimmed = input.trim();
|
||||
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,
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
socket.on("message", (data, isBinary) => {
|
||||
relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
|
||||
});
|
||||
socket.on("close", (code, reason) => {
|
||||
relayTransport.onclose?.(code, reason.toString());
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
|
||||
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 };
|
||||
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, 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// buildRelayWebSocketUrl + parseHostPort live in ../shared/daemon-endpoints.ts
|
||||
|
||||
@@ -99,7 +99,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.bridge.broadcast(message);
|
||||
}
|
||||
|
||||
public async attachExternalSocket(ws: Parameters<WebSocketSessionBridge["attach"]>[0]): Promise<void> {
|
||||
public async attachExternalSocket(
|
||||
ws: Parameters<WebSocketSessionBridge["attach"]>[0]
|
||||
): Promise<void> {
|
||||
const fakeRequest = {
|
||||
url: "/ws",
|
||||
headers: {},
|
||||
|
||||
@@ -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,30 @@ import type pino from "pino";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
|
||||
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;
|
||||
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<WebSocket, Session> = new Map();
|
||||
private readonly sessions: Map<WebSocketLike, Session> = new Map();
|
||||
private clientIdCounter = 0;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
@@ -95,7 +115,7 @@ export class WebSocketSessionBridge {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
public async attach(ws: WebSocket, _request: IncomingMessage): Promise<void> {
|
||||
public async attach(ws: WebSocketLike, _request: IncomingMessage): Promise<void> {
|
||||
const clientId = `client-${++this.clientIdCounter}`;
|
||||
const connectionLogger = this.logger.child({ clientId });
|
||||
|
||||
@@ -141,7 +161,7 @@ export class WebSocketSessionBridge {
|
||||
});
|
||||
}
|
||||
|
||||
private async detach(ws: WebSocket, connectionLogger: pino.Logger, clientId: string): Promise<void> {
|
||||
private async detach(ws: WebSocketLike, connectionLogger: pino.Logger, clientId: string): Promise<void> {
|
||||
const session = this.sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
@@ -154,9 +174,13 @@ export class WebSocketSessionBridge {
|
||||
this.sessions.delete(ws);
|
||||
}
|
||||
|
||||
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
|
||||
private async handleRawMessage(
|
||||
ws: WebSocketLike,
|
||||
data: Buffer | ArrayBuffer | Buffer[] | string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parsed = JSON.parse(data.toString());
|
||||
const buffer = bufferFromWsData(data);
|
||||
const parsed = JSON.parse(buffer.toString());
|
||||
const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
|
||||
if (!parsedMessage.success) {
|
||||
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
|
||||
@@ -252,13 +276,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) {
|
||||
@@ -313,7 +331,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));
|
||||
@@ -442,7 +460,7 @@ export class WebSocketSessionBridge {
|
||||
reason: "finished" | "error" | "permission";
|
||||
}): void {
|
||||
const clientEntries: Array<{
|
||||
ws: WebSocket;
|
||||
ws: WebSocketLike;
|
||||
state: {
|
||||
deviceType: "web" | "mobile" | null;
|
||||
focusedAgentId: string | null;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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<typeof ConnectionOfferV1Schema>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@server/*": ["./*"]
|
||||
|
||||
Reference in New Issue
Block a user