From 178f013bf72891616f795587934ffff33e2d309a Mon Sep 17 00:00:00 2001 From: Florian Glatz Date: Wed, 8 Jul 2026 23:08:36 +0200 Subject: [PATCH 01/39] feat(wallet): OpenLV remote-signer core (protocol, broker, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign with a wallet on your phone over the Open Lavatory protocol (openlv.sh): main publishes signing jobs to a renderer-hosted openlv session (P2P WebRTC, end-to-end encrypted, per-request QR); the phone's answer is verified against the account record before use. - remote signer backend behind the getSigner seam (type 'remote'): personal_sign / eth_signTypedData_v4 with recover-and-compare verification (REMOTE_WRONG_ACCOUNT), stable REMOTE_* error codes mirroring the LEDGER_* registry, EIP-1193 rejection mapping - optional Signer.sendTransaction capability: phones broadcast themselves; transaction-service prefers it and best-effort verifies the reported tx's sender on-chain; coded device errors now pass through its catch unwrapped - renderer session broker (one openlv session per job, dual-purpose QR URL with the session secret in the fragment) + preload IPC bridge that only accepts responses from the chrome renderer - shared signing-utils for EIP-712 wire-payload construction (was duplicated across vault/ledger, now also used by remote) - vendored @openlv bundle (LGPL-3.0, regenerable via npm run vendor:openlv), lazy-loaded off the renderer boot path - integration test: real @openlv stack, both roles, against an in-test MQTT broker — handshake, encryption, signing round-trips, and a relay-sees-only-ciphertext assertion Co-Authored-By: Claude Fable 5 --- jest.config.js | 2 +- package-lock.json | 1048 ++++++++++++++++- package.json | 8 +- scripts/bundle-openlv.js | 70 ++ src/main/identity-manager.js | 1 + src/main/index.js | 2 + src/main/preload.js | 20 + src/main/preload.test.js | 3 +- src/main/wallet/ledger/signer.js | 19 +- .../integration/openlv-protocol.test.js | 263 +++++ src/main/wallet/remote/bridge.js | 102 ++ src/main/wallet/remote/bridge.test.js | 121 ++ src/main/wallet/remote/errors.js | 75 ++ src/main/wallet/remote/errors.test.js | 41 + src/main/wallet/remote/signer.js | 105 ++ src/main/wallet/remote/signer.test.js | 179 +++ src/main/wallet/signers.js | 35 +- src/main/wallet/signers.test.js | 56 +- src/main/wallet/signing-utils.js | 40 + src/main/wallet/transaction-service.js | 51 + src/main/wallet/transaction-service.test.js | 84 ++ src/main/wallet/vault-access.js | 6 +- src/main/wallet/vault-access.test.js | 11 +- src/renderer/index.html | 2 +- src/renderer/lib/wallet-ui.js | 2 + src/renderer/lib/wallet/remote-session.js | 202 ++++ .../lib/wallet/remote-session.test.js | 231 ++++ src/renderer/vendor/openlv.esm.js | 8 + 28 files changed, 2742 insertions(+), 45 deletions(-) create mode 100644 scripts/bundle-openlv.js create mode 100644 src/main/wallet/remote/__tests__/integration/openlv-protocol.test.js create mode 100644 src/main/wallet/remote/bridge.js create mode 100644 src/main/wallet/remote/bridge.test.js create mode 100644 src/main/wallet/remote/errors.js create mode 100644 src/main/wallet/remote/errors.test.js create mode 100644 src/main/wallet/remote/signer.js create mode 100644 src/main/wallet/remote/signer.test.js create mode 100644 src/main/wallet/signing-utils.js create mode 100644 src/renderer/lib/wallet/remote-session.js create mode 100644 src/renderer/lib/wallet/remote-session.test.js create mode 100644 src/renderer/vendor/openlv.esm.js diff --git a/jest.config.js b/jest.config.js index a0f57a89..95c72a1b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,6 +28,6 @@ module.exports = { '^.+\\.js$': 'babel-jest', }, transformIgnorePatterns: [ - '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed)/)', + '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed|@openlv|websocket-mqtt|ts-pattern)/)', ], }; diff --git a/package-lock.json b/package-lock.json index 33ac8ee8..94cf0c7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", @@ -33,14 +35,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "engines": { "node": ">=18" @@ -1468,6 +1473,16 @@ "@babel/core": "^8.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", @@ -1880,6 +1895,448 @@ "node": ">=18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3828,11 +4285,125 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@openlv/core": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@openlv/core/-/core-0.0.2.tgz", + "integrity": "sha512-Q7ISpgQX8YkKw25hs76SgDjsIosFkiA/28P/p9b+MvpnN8cymCJDauWA31SSNSyMOrsOS/iMK4I5VHCcwwC5RA==", + "license": "LGPL-3.0-only", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@openlv/core/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/core/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/session": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/session/-/session-0.0.3.tgz", + "integrity": "sha512-s5Q6BIYAXEnmGuOIW3IQYbY1qW9YS5R5ytgwEjSYWJHiJCOVBu8gcNCQ9NIZ9wI6XLlprKFSdHkw6pzP7pAx4g==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "@openlv/signaling": "0.0.3", + "@openlv/transport": "0.0.3", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/session/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/signaling": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/signaling/-/signaling-0.0.3.tgz", + "integrity": "sha512-U2WBRN05DjC6keeTWG7p2WAcm71XDkHjqC8GMpU6LeCcmoqAHIO/rfLcRZxpr3E+BImSblLVe6nwuImAN/v5tg==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5", + "globals": "^16", + "gun": "^0.2020.1241", + "ts-pattern": "^5", + "websocket-mqtt": "0.0.7" + } + }, + "node_modules/@openlv/signaling/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/transport": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/transport/-/transport-0.0.3.tgz", + "integrity": "sha512-nqQnYk/jATPx25zIHFG409sVlNRrDYjxrqXFkmr+zmSlbBSq29Blk/XzwK/7xCcaiDf4tZu+hnf2V0b2YU2IuQ==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/transport/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -3844,7 +4415,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -3857,7 +4428,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -3867,7 +4438,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -4446,6 +5017,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -4867,6 +5448,19 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -4890,6 +5484,88 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + } + }, + "node_modules/aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mqtt-packet": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-packet/node_modules/mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/aes-js": { "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", @@ -5265,7 +5941,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -7091,6 +7767,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7475,6 +8193,16 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7579,6 +8307,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -7596,6 +8338,37 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==", + "dev": true, + "license": "ISC" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8079,6 +8852,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gun": { + "version": "0.2020.1241", + "resolved": "https://registry.npmjs.org/gun/-/gun-0.2020.1241.tgz", + "integrity": "sha512-rmGqLuJj4fAuZ/0lddCvXHbENPkEnBOBYpq+kXHrwQ5RdNtQ5p0Io99lD1qUXMFmtwNacQ/iqo3VTmjmMyAYZg==", + "license": "(Zlib OR MIT OR Apache-2.0)", + "dependencies": { + "ws": "^7.2.1" + }, + "engines": { + "node": ">=0.8.4" + }, + "optionalDependencies": { + "@peculiar/webcrypto": "^1.1.1" + } + }, + "node_modules/gun/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8273,6 +9082,29 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -10721,6 +11553,97 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "dev": true, + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mqemitter/node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/mqtt-packet/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/mqtt-packet/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11594,6 +12517,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.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", @@ -11686,7 +12619,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11696,12 +12629,22 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.0.0" } }, + "node_modules/qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -12051,6 +12994,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "worker-timers": "^7.0.75" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -12060,6 +13013,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -12728,6 +13692,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -13017,6 +13987,13 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13118,7 +14095,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -13128,6 +14105,12 @@ "tslib": "^2.8.1" } }, + "node_modules/websocket-mqtt": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/websocket-mqtt/-/websocket-mqtt-0.0.7.tgz", + "integrity": "sha512-+33fT52HJ3IlJdybH/em5TNmswwxcDCSyubernwVCaXKf7/RCEg95Lr2U51Cgev1/dqi9dUgOyozhUK/09e4IA==", + "license": "LGPL-3.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13160,6 +14143,43 @@ "node": ">=0.10.0" } }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13261,6 +14281,16 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 41230236..77e15fe2 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "radicle:reset": "rm -rf radicle-data && echo 'Radicle data reset.'", "identity:reset": "rm -rf ant-data ipfs-data radicle-data identity-data && echo 'All identity data reset (ant, ipfs, radicle, vault).'", "dapp:reset": "node scripts/reset-dapp-permissions.js", + "vendor:openlv": "node scripts/bundle-openlv.js", "serve:updates": "node scripts/serve-updates.js", "start:test-updater": "ENABLE_DEV_UPDATER=true npm start", "postinstall": "electron-builder install-app-deps" @@ -185,14 +186,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "dependencies": { "@adraffy/ens-normalize": "^1.11.1", @@ -202,6 +206,8 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", diff --git a/scripts/bundle-openlv.js b/scripts/bundle-openlv.js new file mode 100644 index 00000000..a9e74ffb --- /dev/null +++ b/scripts/bundle-openlv.js @@ -0,0 +1,70 @@ +/** + * Bundle the @openlv/* packages (LGPL-3.0) into a single ES module for the + * chrome renderer, which loads plain `type="module"` scripts and cannot + * resolve bare npm specifiers. + * + * The output (src/renderer/vendor/openlv.esm.js) is committed like the + * other vendor files; re-run this script after bumping the @openlv + * dependencies: + * + * node scripts/bundle-openlv.js + * + * Keeping the LGPL code in one clearly-labelled, regenerable file (rather + * than mixed into app bundles) is also what makes it trivially replaceable, + * as the LGPL asks. + * + * Only the session-host surface is exported: freedom is always the openlv + * "host" (it shows the QR). `connectSession` (the wallet/client role) is + * deliberately left out so tree-shaking drops the gundb/ntfy signaling + * backends it would drag in via dynamic imports. + */ + +const fs = require('fs'); +const path = require('path'); + +const esbuild = require('esbuild'); + +const openlvVersion = require( + path.join(__dirname, '..', 'node_modules', '@openlv', 'session', 'package.json') +).version; + +const ENTRY = ` +export { createSession, SESSION_STATE } from '@openlv/session'; +export { encodeConnectionURL, decodeConnectionURL, OPENLV_PROTOCOL_VERSION } from '@openlv/core'; +export { mqtt } from '@openlv/signaling/mqtt'; +export { webrtc } from '@openlv/transport/webrtc'; +`; + +const BANNER = `/*! + * openlv.esm.js — bundled from @openlv/session@${openlvVersion} and its + * dependencies (https://github.com/v3xlabs/open-lavatory, LGPL-3.0-only). + * Generated by scripts/bundle-openlv.js — do not edit by hand. + */`; + +async function main() { + const outfile = path.join(__dirname, '..', 'src', 'renderer', 'vendor', 'openlv.esm.js'); + + await esbuild.build({ + stdin: { + contents: ENTRY, + resolveDir: path.join(__dirname, '..'), + sourcefile: 'openlv-entry.js', + }, + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outfile, + banner: { js: BANNER }, + legalComments: 'inline', + minify: true, + }); + + const bytes = fs.statSync(outfile).size; + console.log(`Wrote ${outfile} (${(bytes / 1024).toFixed(0)} KiB)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index 9d79e1d2..31a93302 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -793,6 +793,7 @@ async function exportMnemonic() { const WALLET_TYPES = { MNEMONIC: 'mnemonic', LEDGER: 'ledger', + REMOTE: 'remote', // phone / other device signing over openlv }; /** diff --git a/src/main/index.js b/src/main/index.js index 333115a8..4f57da0d 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -181,6 +181,7 @@ const { registerIdentityIpc, hasVault, setBeeLifecycle } = require('./identity-m const { registerQuickUnlockIpc } = require('./quick-unlock'); const { registerWalletIpc } = require('./wallet/wallet-ipc'); const { registerLedgerIpc } = require('./wallet/ledger/ipc'); +const { registerRemoteSignerIpc } = require('./wallet/remote/bridge'); const { registerTokenRegistryIpc } = require('./token-registry'); const { registerRpcManagerIpc } = require('./wallet/rpc-manager'); const { registerNetworkConfigIpc } = require('./networks/network-ipc'); @@ -268,6 +269,7 @@ async function bootstrap() { registerQuickUnlockIpc(); registerWalletIpc(); registerLedgerIpc(); + registerRemoteSignerIpc(); // Let identity (re)injection stop the Bee node before wiping its statestore // (which it holds a LevelDB lock on) and restart it with the new key. Without diff --git a/src/main/preload.js b/src/main/preload.js index f2da956b..735661c8 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -415,6 +415,26 @@ contextBridge.exposeInMainWorld('ledger', { addAccount: (name, address, path) => ipcRenderer.invoke('wallet:add-ledger-wallet', name, address, path), }); +// Remote (phone) signing: main publishes signing jobs here; the renderer +// session broker (lib/wallet/remote-session.js) shows the QR, tunnels the +// request to the phone over openlv, and responds with the result. +contextBridge.exposeInMainWorld('remoteSigner', { + // Main asks the renderer to run a signing job. Returns a disposer. + onRequest: (callback) => { + const handler = (_event, job) => callback(job); + ipcRenderer.on('remote-signer:request', handler); + return () => ipcRenderer.removeListener('remote-signer:request', handler); + }, + // Main gave up on a job (timeout) — close its QR dialog. Returns a disposer. + onAbort: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on('remote-signer:abort', handler); + return () => ipcRenderer.removeListener('remote-signer:abort', handler); + }, + // Job outcome: { jobId, result } or { jobId, error: {code, message} }. + respond: (payload) => ipcRenderer.send('remote-signer:response', payload), +}); + contextBridge.exposeInMainWorld('swarmNode', { getStamps: () => ipcRenderer.invoke('swarm:get-stamps'), getStorageCost: (sizeGB, durationDays) => ipcRenderer.invoke('swarm:get-storage-cost', sizeGB, durationDays), diff --git a/src/main/preload.test.js b/src/main/preload.test.js index c0d9ea02..af8ec9d3 100644 --- a/src/main/preload.test.js +++ b/src/main/preload.test.js @@ -83,7 +83,7 @@ describe('preload', () => { beeApiEnv: 'http://127.0.0.1:1700', }); - expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(21); + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(22); expect(Object.keys(exposures)).toEqual([ 'nodeConfig', 'internalPages', @@ -97,6 +97,7 @@ describe('preload', () => { 'quickUnlock', 'wallet', 'ledger', + 'remoteSigner', 'swarmNode', 'networks', 'payments', diff --git a/src/main/wallet/ledger/signer.js b/src/main/wallet/ledger/signer.js index ea252894..ea2803f3 100644 --- a/src/main/wallet/ledger/signer.js +++ b/src/main/wallet/ledger/signer.js @@ -26,6 +26,7 @@ const { Transaction, Signature, TypedDataEncoder } = require('ethers'); const { withEthApp } = require('./transport'); const { LEDGER_ERROR_CODES, createLedgerError } = require('./errors'); +const { getEip712WirePayload, messageToBytes } = require('../signing-utils'); /** hw-app-eth returns r/s as bare hex and v as hex string or number. */ function toEthersSignature(sig) { @@ -36,13 +37,6 @@ function toEthersSignature(sig) { }); } -/** Types with EIP712Domain stripped, as ethers' hashing helpers expect. */ -function withoutDomainType(types) { - const stripped = { ...types }; - delete stripped.EIP712Domain; - return stripped; -} - /** * @param {{address: string, path: string}} record - Ledger wallet record from vault-meta * @returns {import('../signers').Signer} @@ -79,9 +73,7 @@ function createLedgerBackend(record) { }, signMessage: async (message) => { - const messageHex = Buffer.isBuffer(message) - ? message.toString('hex') - : Buffer.from(String(message), 'utf8').toString('hex'); + const messageHex = messageToBytes(message).toString('hex'); return withVerifiedDevice(async (eth) => { const sig = await eth.signPersonalMessage(record.path, messageHex); @@ -90,11 +82,8 @@ function createLedgerBackend(record) { }, signTypedData: async (typedData) => { - // The device wants the full EIP-712 wire payload (EIP712Domain in - // types + explicit primaryType); ethers-style callers may omit - // both, and getPayload reconstructs them canonically. - const strippedTypes = withoutDomainType(typedData.types); - const payload = TypedDataEncoder.getPayload(typedData.domain || {}, strippedTypes, typedData.message); + // The device wants the full EIP-712 wire payload. + const { strippedTypes, payload } = getEip712WirePayload(typedData); return withVerifiedDevice(async (eth) => { let sig; diff --git a/src/main/wallet/remote/__tests__/integration/openlv-protocol.test.js b/src/main/wallet/remote/__tests__/integration/openlv-protocol.test.js new file mode 100644 index 00000000..ec69e6b6 --- /dev/null +++ b/src/main/wallet/remote/__tests__/integration/openlv-protocol.test.js @@ -0,0 +1,263 @@ +/** + * OpenLV protocol integration test — the REAL @openlv stack, both roles. + * + * Freedom's host role (what the renderer broker runs) talks to a + * simulated phone (the openlv client role, answering wallet JSON-RPC + * with a real test key) through a local MQTT broker started in-test, so + * the full spec surface is exercised deterministically and offline: + * URI encode/decode, the five-step signaling handshake, symmetric → + * peer-key encryption, and correlated request/response envelopes. + * + * Node has no RTCPeerConnection, so the WebRTC transport phase is + * replaced by a minimal in-spec relay transport that carries the + * session envelopes through the (already end-to-end encrypted) + * signaling channel. WebRTC itself runs in the Playwright E2E tests, + * where both peers are real Chromium contexts. + */ + +const http = require('http'); + +const { Wallet, verifyMessage, verifyTypedData, getBytes } = require('ethers'); + +const { createSession, connectSession } = require('@openlv/session'); +const { encodeConnectionURL } = require('@openlv/core'); +const { mqtt } = require('@openlv/signaling/mqtt'); + +jest.setTimeout(30000); + +// Anvil/Hardhat-default test key — well-known, never funded on mainnet. +const TEST_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const phoneWallet = new Wallet(TEST_PRIVATE_KEY); + +// --------------------------------------------------------------------------- +// Local MQTT-over-WebSocket broker (what `s=` points at) +// --------------------------------------------------------------------------- + +async function startLocalBroker() { + const aedes = require('aedes')(); + const { WebSocketServer, createWebSocketStream } = require('ws'); + + const server = http.createServer(); + const wss = new WebSocketServer({ server }); + wss.on('connection', (socket) => { + aedes.handle(createWebSocketStream(socket)); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + return { + aedes, + url: `ws://127.0.0.1:${port}/mqtt`, + close: async () => { + await new Promise((resolve) => aedes.close(resolve)); + wss.close(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +// --------------------------------------------------------------------------- +// Relay transport: session envelopes ride the encrypted signaling channel +// --------------------------------------------------------------------------- + +function relayTransport() { + const { EventEmitter } = require('eventemitter3'); + + return ({ isHost, subsend, onmessage }) => { + const emitter = new EventEmitter(); + let connected = false; + let stopped = false; + + const setConnected = () => { + if (connected) return; + connected = true; + emitter.emit('state_change', 'connected'); + }; + + return { + type: 'relay', + emitter, + // The host re-offers until the client answers — its signaling can + // reach the encrypted state a beat before the client's does. + setup: async () => { + if (!isHost) return; + for (let i = 0; i < 40 && !connected && !stopped; i++) { + await subsend({ type: 'offer', payload: 'relay' }); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }, + teardown: async () => { + stopped = true; + }, + send: async (message) => { + // App traffic tunnels as a "candidate" negotiation frame — the + // only free-form payload carrier in the transport message set. + await subsend({ type: 'candidate', payload: JSON.stringify(message) }); + }, + handle: async (message) => { + if (message.type === 'offer') { + await subsend({ type: 'answer', payload: 'relay' }); + setConnected(); + } else if (message.type === 'answer') { + setConnected(); + } else if (message.type === 'candidate') { + onmessage(JSON.parse(message.payload)); + } + }, + waitFor: async () => {}, + }; + }; +} + +// --------------------------------------------------------------------------- +// Simulated phone: openlv client role + wallet JSON-RPC over a test key +// --------------------------------------------------------------------------- + +const FAKE_TX_HASH = '0x' + '5a'.repeat(32); + +function createPhoneHandler(receivedRequests) { + return async (payload) => { + receivedRequests.push(payload); + const { method, params } = payload; + switch (method) { + case 'eth_requestAccounts': + case 'eth_accounts': + return { result: [phoneWallet.address] }; + case 'personal_sign': + return { result: await phoneWallet.signMessage(getBytes(params[0])) }; + case 'eth_signTypedData_v4': { + const typed = JSON.parse(params[1]); + const types = { ...typed.types }; + delete types.EIP712Domain; + return { result: await phoneWallet.signTypedData(typed.domain, types, typed.message) }; + } + case 'eth_sendTransaction': + return { result: FAKE_TX_HASH }; + default: + return { error: { code: -32601, message: 'Method not found' } }; + } + }; +} + +// --------------------------------------------------------------------------- + +describe('openlv protocol round-trip (real stack, local broker)', () => { + let broker; + let hostSession; + let phoneSession; + let receivedRequests; + let relayedFrames; + + const send = (message) => hostSession.send(message, 10000, 15000); + + beforeAll(async () => { + // The @openlv packages log every protocol step unconditionally. + jest.spyOn(console, 'log').mockImplementation(() => {}); + + broker = await startLocalBroker(); + relayedFrames = []; + broker.aedes.on('publish', (packet) => { + // Ignore aedes' internal $SYS topics. + if (!packet.topic.startsWith('$SYS')) { + relayedFrames.push(packet.payload.toString('utf8')); + } + }); + + receivedRequests = []; + + // Freedom's role: host a session, hand out the URI (the QR content). + hostSession = await createSession( + { p: 'mqtt', s: broker.url }, + mqtt, + [relayTransport()], + async () => ({ error: { code: -32601, message: 'Method not found' } }), + ); + const uri = encodeConnectionURL(hostSession.getHandshakeParameters()); + expect(uri).toMatch(/^openlv:\/\/[A-Za-z0-9_-]{16}@1\?/); + + // The phone's role: decode the URI and join. + phoneSession = await connectSession(uri, createPhoneHandler(receivedRequests), [relayTransport()]); + + await hostSession.connect(); + await phoneSession.connect(); + await hostSession.waitForLink(); + }); + + afterAll(async () => { + await Promise.allSettled([hostSession?.close(), phoneSession?.close()]); + await broker?.close(); + }); + + test('discovers the phone account over eth_requestAccounts', async () => { + const { result } = await send({ method: 'eth_requestAccounts', params: [] }); + expect(result).toEqual([phoneWallet.address]); + }); + + test('personal_sign round-trips and the signature verifies', async () => { + const message = Buffer.from('freedom openlv test', 'utf8'); + const { result } = await send({ + method: 'personal_sign', + params: ['0x' + message.toString('hex'), phoneWallet.address], + }); + expect(verifyMessage(message, result)).toBe(phoneWallet.address); + }); + + test('eth_signTypedData_v4 round-trips and the signature verifies', async () => { + const domain = { name: 'Freedom Test', version: '1', chainId: 100 }; + const types = { + Payment: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + }; + const message = { to: phoneWallet.address, amount: '42' }; + const payload = { + domain, + primaryType: 'Payment', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ], + ...types, + }, + message, + }; + + const { result } = await send({ + method: 'eth_signTypedData_v4', + params: [phoneWallet.address, JSON.stringify(payload)], + }); + expect(verifyTypedData(domain, types, message, result)).toBe(phoneWallet.address); + }); + + test('eth_sendTransaction round-trips the phone-reported hash', async () => { + const { result } = await send({ + method: 'eth_sendTransaction', + params: [{ from: phoneWallet.address, to: phoneWallet.address, value: '0x1', chainId: '0x64' }], + }); + expect(result).toBe(FAKE_TX_HASH); + const sent = receivedRequests.find((r) => r.method === 'eth_sendTransaction'); + expect(sent.params[0]).toMatchObject({ from: phoneWallet.address, chainId: '0x64' }); + }); + + test('unknown methods come back as JSON-RPC errors, not hangs', async () => { + const { error } = await send({ method: 'eth_selfdestruct', params: [] }); + expect(error).toMatchObject({ code: -32601 }); + }); + + test('the relay never sees plaintext: every frame is role-addressed ciphertext', () => { + expect(relayedFrames.length).toBeGreaterThan(0); + for (const frame of relayedFrames) { + // Spec wire format: + expect(frame).toMatch(/^[hx][hc][A-Za-z0-9+/=]+$/); + } + const everything = relayedFrames.join('\n'); + // Neither the signed message nor any signature ever crossed in the clear. + expect(everything).not.toContain('freedom openlv test'); + expect(everything).not.toContain('personal_sign'); + expect(everything).not.toContain(phoneWallet.address); + }); +}); diff --git a/src/main/wallet/remote/bridge.js b/src/main/wallet/remote/bridge.js new file mode 100644 index 00000000..e13e5090 --- /dev/null +++ b/src/main/wallet/remote/bridge.js @@ -0,0 +1,102 @@ +/** + * Remote-signer IPC bridge. + * + * The openlv session lives in the chrome renderer (WebRTC is native + * there), but signing policy stays in main: the remote signer backend + * (./signer.js) calls `requestRemoteSignature` here, which publishes the + * job to the renderer session broker and waits for its response. Main + * never trusts the answer as-is — the backend verifies every signature + * against the account record before returning it. + * + * One channel pair: + * main → renderer 'remote-signer:request' { jobId, walletIndex, address, method, params } + * renderer → main 'remote-signer:response' { jobId, result } | { jobId, error: {code, message} } + * plus 'remote-signer:abort' (main → renderer) when a job dies in main + * (timeout) so the QR dialog can close itself. + * + * Responses are only accepted from the webContents the job was sent to — + * a dApp webview must not be able to answer (or cancel) signing jobs. + */ + +const { randomUUID } = require('crypto'); + +const { ipcMain, BrowserWindow } = require('electron'); + +const { REMOTE_ERROR_CODES, createRemoteError, mapRemoteRpcError } = require('./errors'); + +// Scanning a QR and confirming on a phone is slow; the renderer session +// enforces its own (shorter) protocol timeouts — this is the backstop. +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +/** jobId → {resolve, reject, timer, webContents} */ +const pendingJobs = new Map(); + +function getTargetWindow() { + return BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null; +} + +function settleJob(jobId, settle) { + const job = pendingJobs.get(jobId); + if (!job) return; + pendingJobs.delete(jobId); + clearTimeout(job.timer); + settle(job); +} + +/** + * Publish a signing job to the renderer session broker and await the + * phone's answer. + * + * @param {{walletIndex: number, address: string, method: string, params: unknown[]}} job + * @param {number} [timeoutMs] + * @returns {Promise} The JSON-RPC result from the phone (unverified). + */ +function requestRemoteSignature(job, timeoutMs = DEFAULT_TIMEOUT_MS) { + const win = getTargetWindow(); + if (!win || win.webContents.isDestroyed()) { + return Promise.reject(createRemoteError(REMOTE_ERROR_CODES.NO_UI)); + } + + const jobId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + settleJob(jobId, ({ webContents }) => { + if (!webContents.isDestroyed()) { + webContents.send('remote-signer:abort', { jobId }); + } + reject(createRemoteError(REMOTE_ERROR_CODES.TIMEOUT)); + }); + }, timeoutMs); + + pendingJobs.set(jobId, { resolve, reject, timer, webContents: win.webContents }); + + win.webContents.send('remote-signer:request', { jobId, ...job }); + }); +} + +function registerRemoteSignerIpc() { + ipcMain.on('remote-signer:response', (event, payload) => { + const { jobId, result, error } = payload || {}; + const job = pendingJobs.get(jobId); + if (!job) return; // already settled (timeout) or forged jobId + if (event.sender.id !== job.webContents.id) { + console.warn('[RemoteSigner] Ignoring response from unexpected webContents', event.sender.id); + return; + } + settleJob(jobId, ({ resolve, reject }) => { + if (error) { + // rpcCode = a JSON-RPC error object straight from the phone wallet + // (EIP-1193 codes); plain code = a REMOTE_* the renderer minted. + reject( + error.rpcCode !== undefined + ? mapRemoteRpcError({ code: error.rpcCode, message: error.message }) + : createRemoteError(error.code || REMOTE_ERROR_CODES.UNKNOWN, error.message), + ); + } else { + resolve(result); + } + }); + }); +} + +module.exports = { requestRemoteSignature, registerRemoteSignerIpc }; diff --git a/src/main/wallet/remote/bridge.test.js b/src/main/wallet/remote/bridge.test.js new file mode 100644 index 00000000..69e31cd1 --- /dev/null +++ b/src/main/wallet/remote/bridge.test.js @@ -0,0 +1,121 @@ +/** + * The bridge is main's only path to the renderer session broker; these + * tests fake electron's window/IPC surface and drive both directions: + * job publication (webContents.send) and response settlement (ipcMain.on). + */ + +const mockWebContents = { + id: 7, + isDestroyed: jest.fn(() => false), + send: jest.fn(), +}; +const mockWindow = { webContents: mockWebContents }; + +let mockFocusedWindow = mockWindow; +let mockAllWindows = [mockWindow]; +const mockIpcMainHandlers = {}; + +jest.mock('electron', () => ({ + ipcMain: { + on: (channel, handler) => { + mockIpcMainHandlers[channel] = handler; + }, + }, + BrowserWindow: { + getFocusedWindow: () => mockFocusedWindow, + getAllWindows: () => mockAllWindows, + }, +})); + +const { requestRemoteSignature, registerRemoteSignerIpc } = require('./bridge'); + +registerRemoteSignerIpc(); +const respond = (senderId, payload) => + mockIpcMainHandlers['remote-signer:response']({ sender: { id: senderId } }, payload); + +const JOB = { walletIndex: 3, address: '0xabc', method: 'personal_sign', params: ['0x68690a', '0xabc'] }; + +/** The jobId assigned to the most recently published job. */ +function sentJobId() { + const calls = mockWebContents.send.mock.calls.filter(([ch]) => ch === 'remote-signer:request'); + return calls[calls.length - 1][1].jobId; +} + +beforeEach(() => { + mockWebContents.send.mockClear(); + mockWebContents.isDestroyed.mockReturnValue(false); + mockFocusedWindow = mockWindow; + mockAllWindows = [mockWindow]; +}); + +describe('requestRemoteSignature', () => { + test('publishes the job to the renderer and resolves with its result', async () => { + const promise = requestRemoteSignature(JOB); + + expect(mockWebContents.send).toHaveBeenCalledWith( + 'remote-signer:request', + expect.objectContaining({ ...JOB, jobId: expect.any(String) }), + ); + + respond(7, { jobId: sentJobId(), result: '0xsignature' }); + await expect(promise).resolves.toBe('0xsignature'); + }); + + test('rejects with the renderer-reported error code and message', async () => { + const promise = requestRemoteSignature(JOB); + respond(7, { jobId: sentJobId(), error: { code: 'REMOTE_USER_CANCELLED' } }); + await expect(promise).rejects.toMatchObject({ code: 'REMOTE_USER_CANCELLED' }); + }); + + test('maps phone JSON-RPC errors (EIP-1193) to REMOTE_* codes', async () => { + const promise = requestRemoteSignature(JOB); + respond(7, { jobId: sentJobId(), error: { rpcCode: 4001, message: 'User denied.' } }); + await expect(promise).rejects.toMatchObject({ code: 'REMOTE_USER_REJECTED' }); + }); + + test('falls back to the first window when none is focused', async () => { + mockFocusedWindow = null; + const promise = requestRemoteSignature(JOB); + respond(7, { jobId: sentJobId(), result: 'ok' }); + await expect(promise).resolves.toBe('ok'); + }); + + test('rejects with REMOTE_NO_UI when no window exists', async () => { + mockFocusedWindow = null; + mockAllWindows = []; + await expect(requestRemoteSignature(JOB)).rejects.toMatchObject({ code: 'REMOTE_NO_UI' }); + }); + + test('ignores responses from a webContents the job was not sent to', async () => { + const promise = requestRemoteSignature(JOB); + const jobId = sentJobId(); + + // A dApp webview must not be able to answer signing jobs. + respond(999, { jobId, result: '0xforged' }); + + respond(7, { jobId, result: '0xreal' }); + await expect(promise).resolves.toBe('0xreal'); + }); + + test('ignores responses for unknown or already-settled jobs', async () => { + expect(() => respond(7, { jobId: 'no-such-job', result: 'x' })).not.toThrow(); + expect(() => respond(7, undefined)).not.toThrow(); + }); + + test('times out with REMOTE_TIMEOUT and tells the renderer to abort', async () => { + jest.useFakeTimers(); + try { + const promise = requestRemoteSignature(JOB, 1000); + const jobId = sentJobId(); + + jest.advanceTimersByTime(1001); + await expect(promise).rejects.toMatchObject({ code: 'REMOTE_TIMEOUT' }); + expect(mockWebContents.send).toHaveBeenCalledWith('remote-signer:abort', { jobId }); + + // A late renderer response for the dead job is ignored. + expect(() => respond(7, { jobId, result: '0xlate' })).not.toThrow(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/main/wallet/remote/errors.js b/src/main/wallet/remote/errors.js new file mode 100644 index 00000000..3b2fd9c6 --- /dev/null +++ b/src/main/wallet/remote/errors.js @@ -0,0 +1,75 @@ +/** + * Remote-signer error mapping. + * + * Remote (phone) signing fails in ways the renderer must distinguish to + * drive the QR/waiting UX: nobody scanned in time, the user dismissed the + * dialog in the browser, the phone's wallet rejected the request, or the + * phone answered from a different account than the one on record. Stable + * REMOTE_* codes mirror the LEDGER_* registry (see ../ledger/errors.js) + * so approval UIs handle both hardware families the same way. + */ + +const REMOTE_ERROR_CODES = { + TIMEOUT: 'REMOTE_TIMEOUT', + USER_CANCELLED: 'REMOTE_USER_CANCELLED', + USER_REJECTED: 'REMOTE_USER_REJECTED', + WRONG_ACCOUNT: 'REMOTE_WRONG_ACCOUNT', + BAD_RESPONSE: 'REMOTE_BAD_RESPONSE', + NO_UI: 'REMOTE_NO_UI', + UNSUPPORTED: 'REMOTE_UNSUPPORTED', + UNKNOWN: 'REMOTE_UNKNOWN', +}; + +const MESSAGES = { + [REMOTE_ERROR_CODES.TIMEOUT]: 'Your phone did not respond in time. Try again with a new QR code.', + [REMOTE_ERROR_CODES.USER_CANCELLED]: 'Request cancelled.', + [REMOTE_ERROR_CODES.USER_REJECTED]: 'Request rejected on your phone.', + [REMOTE_ERROR_CODES.WRONG_ACCOUNT]: + 'Your phone answered from a different account than this wallet. Select the right account on your phone and try again.', + [REMOTE_ERROR_CODES.BAD_RESPONSE]: 'Your phone sent an invalid response. Try again.', + [REMOTE_ERROR_CODES.NO_UI]: 'No browser window available to show the signing QR code.', + [REMOTE_ERROR_CODES.UNSUPPORTED]: 'This request type is not supported by phone accounts.', + [REMOTE_ERROR_CODES.UNKNOWN]: 'Phone signing failed. Try again.', +}; + +/** + * Mint an error from the code registry. + * + * @param {string} code - One of REMOTE_ERROR_CODES + * @param {string} [message] - Override the registry message (e.g. to + * surface the phone wallet's own error text). + * @returns {Error & {code: string}} + */ +function createRemoteError(code, message) { + const err = new Error(message || MESSAGES[code] || MESSAGES[REMOTE_ERROR_CODES.UNKNOWN]); + err.code = code; + return err; +} + +// EIP-1193 provider error codes a phone wallet may return. +const RPC_CODE_TO_CODE = { + 4001: REMOTE_ERROR_CODES.USER_REJECTED, // user rejected request + 4100: REMOTE_ERROR_CODES.WRONG_ACCOUNT, // unauthorized account + 4200: REMOTE_ERROR_CODES.UNSUPPORTED, // unsupported method +}; + +/** + * Map a JSON-RPC error object returned by the phone wallet + * (`{code, message}`) into a stable REMOTE_* error. + * + * @param {{code?: number, message?: string}|unknown} rpcError + * @returns {Error & {code: string}} + */ +function mapRemoteRpcError(rpcError) { + const rpcCode = rpcError && typeof rpcError === 'object' ? rpcError.code : undefined; + const code = RPC_CODE_TO_CODE[rpcCode] || REMOTE_ERROR_CODES.UNKNOWN; + const phoneMessage = + rpcError && typeof rpcError === 'object' && typeof rpcError.message === 'string' + ? rpcError.message + : undefined; + // User rejection keeps our neutral message; other failures surface the + // phone wallet's own text since it names the actual problem. + return createRemoteError(code, code === REMOTE_ERROR_CODES.USER_REJECTED ? undefined : phoneMessage); +} + +module.exports = { REMOTE_ERROR_CODES, createRemoteError, mapRemoteRpcError }; diff --git a/src/main/wallet/remote/errors.test.js b/src/main/wallet/remote/errors.test.js new file mode 100644 index 00000000..0a6881cf --- /dev/null +++ b/src/main/wallet/remote/errors.test.js @@ -0,0 +1,41 @@ +const { REMOTE_ERROR_CODES, createRemoteError, mapRemoteRpcError } = require('./errors'); + +describe('remote errors', () => { + test('createRemoteError mints code + user-facing message from the registry', () => { + const err = createRemoteError(REMOTE_ERROR_CODES.TIMEOUT); + expect(err.code).toBe('REMOTE_TIMEOUT'); + expect(err.message).toMatch(/did not respond/); + }); + + test('createRemoteError accepts a message override', () => { + const err = createRemoteError(REMOTE_ERROR_CODES.UNKNOWN, 'gas too low'); + expect(err.code).toBe('REMOTE_UNKNOWN'); + expect(err.message).toBe('gas too low'); + }); + + test('maps EIP-1193 user rejection (4001) with our neutral message', () => { + const err = mapRemoteRpcError({ code: 4001, message: 'MetaMask Tx Signature: User denied.' }); + expect(err.code).toBe(REMOTE_ERROR_CODES.USER_REJECTED); + // The phone wallet's own rejection text is noise; ours is consistent. + expect(err.message).toMatch(/rejected on your phone/i); + }); + + test.each([ + [4100, REMOTE_ERROR_CODES.WRONG_ACCOUNT], + [4200, REMOTE_ERROR_CODES.UNSUPPORTED], + ])('maps EIP-1193 code %s', (rpcCode, expected) => { + expect(mapRemoteRpcError({ code: rpcCode }).code).toBe(expected); + }); + + test('unknown RPC errors keep the phone message but map to UNKNOWN', () => { + const err = mapRemoteRpcError({ code: -32000, message: 'intrinsic gas too low' }); + expect(err.code).toBe(REMOTE_ERROR_CODES.UNKNOWN); + expect(err.message).toBe('intrinsic gas too low'); + }); + + test('malformed RPC errors map to UNKNOWN with the registry message', () => { + const err = mapRemoteRpcError(undefined); + expect(err.code).toBe(REMOTE_ERROR_CODES.UNKNOWN); + expect(err.message).toMatch(/failed/i); + }); +}); diff --git a/src/main/wallet/remote/signer.js b/src/main/wallet/remote/signer.js new file mode 100644 index 00000000..0262aa5d --- /dev/null +++ b/src/main/wallet/remote/signer.js @@ -0,0 +1,105 @@ +/** + * Remote (phone) signing backend for the wallet signer factory. + * + * getAddress serves the address stored on the account record (learned via + * eth_requestAccounts when the account was added) — no vault involved. + * + * Signing methods publish a JSON-RPC job to the renderer session broker + * (see ./bridge.js), which shows a QR code, tunnels the request to the + * phone over openlv, and returns the phone's answer. The answer is never + * trusted: signatures must recover to the account's address (a phone + * answering from a different account fails with REMOTE_WRONG_ACCOUNT). + * + * Phone wallets expose `eth_sendTransaction`, not raw transaction + * signing, so this backend implements the optional `sendTransaction` + * capability instead of `signTransaction`: the phone picks the nonce, + * estimates gas, and broadcasts through its own RPC. The reported hash's + * `from` is verified on-chain by transaction-service (the provider layer + * — this module never touches RPC). + */ + +const { verifyMessage, verifyTypedData, toQuantity } = require('ethers'); + +const { requestRemoteSignature } = require('./bridge'); +const { REMOTE_ERROR_CODES, createRemoteError } = require('./errors'); +const { getEip712WirePayload, messageToBytes } = require('../signing-utils'); + +const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; +const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/; + +/** + * @param {{index: number, address: string}} record - Remote wallet record from vault-meta + * @returns {import('../signers').Signer & {sendTransaction: (tx: object) => Promise}} + */ +function createRemoteBackend(record) { + const request = (method, params) => + requestRemoteSignature({ walletIndex: record.index, address: record.address, method, params }); + + /** Shape-check the phone's signature and require it to recover to the account. */ + function verifiedSignature(signature, recover) { + if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature)) { + throw createRemoteError(REMOTE_ERROR_CODES.BAD_RESPONSE); + } + if (recover(signature).toLowerCase() !== record.address.toLowerCase()) { + throw createRemoteError(REMOTE_ERROR_CODES.WRONG_ACCOUNT); + } + return signature; + } + + return { + getAddress: async () => record.address, + + // Phone wallets have no raw-signing RPC; transactions go through + // sendTransaction below (the signer factory advertises it, and + // transaction-service prefers it when present). + signTransaction: async () => { + throw createRemoteError(REMOTE_ERROR_CODES.UNSUPPORTED); + }, + + signMessage: async (message) => { + const bytes = messageToBytes(message); + const signature = await request('personal_sign', [ + '0x' + bytes.toString('hex'), + record.address, + ]); + return verifiedSignature(signature, (sig) => verifyMessage(bytes, sig)); + }, + + signTypedData: async (typedData) => { + // eth_signTypedData_v4 takes the full EIP-712 wire payload. + const { domain, strippedTypes, payload } = getEip712WirePayload(typedData); + const signature = await request('eth_signTypedData_v4', [ + record.address, + JSON.stringify(payload), + ]); + return verifiedSignature(signature, (sig) => + verifyTypedData(domain, strippedTypes, typedData.message, sig)); + }, + + /** + * Ask the phone to sign AND broadcast. Only intent fields go over — + * the phone re-estimates gas, picks the nonce, and shows its own fee + * UI, exactly as it would for one of its own dApps. + * + * @param {{to: string, value?: string|bigint, data?: string, chainId: number}} tx + * @returns {Promise} Transaction hash reported by the phone. + */ + sendTransaction: async (tx) => { + const txParam = { + from: record.address, + to: tx.to, + value: toQuantity(tx.value ?? 0), + chainId: toQuantity(tx.chainId), + }; + if (tx.data) txParam.data = tx.data; + + const hash = await request('eth_sendTransaction', [txParam]); + if (typeof hash !== 'string' || !TX_HASH_RE.test(hash)) { + throw createRemoteError(REMOTE_ERROR_CODES.BAD_RESPONSE); + } + return hash; + }, + }; +} + +module.exports = { createRemoteBackend }; diff --git a/src/main/wallet/remote/signer.test.js b/src/main/wallet/remote/signer.test.js new file mode 100644 index 00000000..d6599c10 --- /dev/null +++ b/src/main/wallet/remote/signer.test.js @@ -0,0 +1,179 @@ +/** + * The fake phone answers with a real (well-known test) key, so every + * assertion exercises actual cryptographic round-trips: the JSON-RPC + * request shapes the backend publishes, and the recover-and-compare + * verification of what comes back. + */ + +const { Wallet, verifyMessage } = require('ethers'); + +// Anvil/Hardhat-default test keys — well-known, never funded on mainnet. +const TEST_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const OTHER_PRIVATE_KEY = '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a'; +const phoneWallet = new Wallet(TEST_PRIVATE_KEY); +const strangerWallet = new Wallet(OTHER_PRIVATE_KEY); + +const RECORD = { + index: 4, + name: 'My Phone', + address: phoneWallet.address, + type: 'remote', +}; + +const mockRequestRemoteSignature = jest.fn(); +jest.mock('./bridge', () => ({ + requestRemoteSignature: (...args) => mockRequestRemoteSignature(...args), +})); + +const { createRemoteBackend } = require('./signer'); + +const backend = createRemoteBackend(RECORD); + +beforeEach(() => { + mockRequestRemoteSignature.mockReset(); +}); + +describe('getAddress', () => { + test('serves the stored address without a phone round-trip', async () => { + await expect(backend.getAddress()).resolves.toBe(phoneWallet.address); + expect(mockRequestRemoteSignature).not.toHaveBeenCalled(); + }); +}); + +describe('signTransaction', () => { + test('is unsupported — phones only expose eth_sendTransaction', async () => { + await expect(backend.signTransaction({ to: '0x1', chainId: 1 })).rejects.toMatchObject({ + code: 'REMOTE_UNSUPPORTED', + }); + expect(mockRequestRemoteSignature).not.toHaveBeenCalled(); + }); +}); + +describe('signMessage', () => { + test('publishes personal_sign over the message bytes and returns a verified signature', async () => { + const message = Buffer.from('hello freedom', 'utf8'); + mockRequestRemoteSignature.mockImplementation(async ({ method, params }) => { + expect(method).toBe('personal_sign'); + expect(params).toEqual(['0x' + message.toString('hex'), phoneWallet.address]); + return phoneWallet.signMessage(message); + }); + + const signature = await backend.signMessage(message); + expect(mockRequestRemoteSignature).toHaveBeenCalledWith( + expect.objectContaining({ walletIndex: 4, address: phoneWallet.address }), + ); + // Round-trip: the returned signature really recovers to the account. + expect(verifyMessage(message, signature)).toBe(phoneWallet.address); + }); + + test('utf8 string messages sign over their bytes', async () => { + mockRequestRemoteSignature.mockImplementation(async ({ params }) => { + expect(params[0]).toBe('0x' + Buffer.from('plain text', 'utf8').toString('hex')); + return phoneWallet.signMessage('plain text'); + }); + await expect(backend.signMessage('plain text')).resolves.toMatch(/^0x/); + }); + + test('rejects a signature from a different account with REMOTE_WRONG_ACCOUNT', async () => { + mockRequestRemoteSignature.mockImplementation(async () => strangerWallet.signMessage('hello')); + await expect(backend.signMessage('hello')).rejects.toMatchObject({ + code: 'REMOTE_WRONG_ACCOUNT', + }); + }); + + test('rejects malformed responses with REMOTE_BAD_RESPONSE', async () => { + mockRequestRemoteSignature.mockResolvedValue('not-a-signature'); + await expect(backend.signMessage('hello')).rejects.toMatchObject({ + code: 'REMOTE_BAD_RESPONSE', + }); + }); +}); + +const TYPED_DATA = { + domain: { name: 'Freedom Test', version: '1', chainId: 100 }, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ], + Payment: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + }, + message: { to: strangerWallet.address, amount: '1000' }, +}; + +const strippedTypes = { Payment: TYPED_DATA.types.Payment }; + +describe('signTypedData', () => { + test('publishes eth_signTypedData_v4 with the full wire payload and verifies the signature', async () => { + mockRequestRemoteSignature.mockImplementation(async ({ method, params }) => { + expect(method).toBe('eth_signTypedData_v4'); + expect(params[0]).toBe(phoneWallet.address); + const payload = JSON.parse(params[1]); + // Wire shape: EIP712Domain restored into types, explicit primaryType. + expect(payload.primaryType).toBe('Payment'); + expect(payload.types.EIP712Domain).toBeDefined(); + // getPayload normalizes quantities to hex on the wire. + expect(payload.domain).toEqual({ ...TYPED_DATA.domain, chainId: '0x64' }); + return phoneWallet.signTypedData(TYPED_DATA.domain, strippedTypes, TYPED_DATA.message); + }); + + const signature = await backend.signTypedData(TYPED_DATA); + expect(signature).toMatch(/^0x[0-9a-f]{130}$/i); + }); + + test('accepts ethers-style payloads without EIP712Domain in types', async () => { + mockRequestRemoteSignature.mockImplementation(async ({ params }) => { + expect(JSON.parse(params[1]).types.EIP712Domain).toBeDefined(); + return phoneWallet.signTypedData(TYPED_DATA.domain, strippedTypes, TYPED_DATA.message); + }); + await expect( + backend.signTypedData({ domain: TYPED_DATA.domain, types: strippedTypes, message: TYPED_DATA.message }), + ).resolves.toMatch(/^0x/); + }); + + test('rejects a typed-data signature from a different account', async () => { + mockRequestRemoteSignature.mockImplementation(async () => + strangerWallet.signTypedData(TYPED_DATA.domain, strippedTypes, TYPED_DATA.message)); + await expect(backend.signTypedData(TYPED_DATA)).rejects.toMatchObject({ + code: 'REMOTE_WRONG_ACCOUNT', + }); + }); +}); + +describe('sendTransaction', () => { + const TX = { to: strangerWallet.address, value: '1500000000000000', data: '0xabcdef', chainId: 100 }; + const HASH = '0x' + 'ab'.repeat(32); + + test('publishes eth_sendTransaction with hex quantities and returns the phone-reported hash', async () => { + mockRequestRemoteSignature.mockImplementation(async ({ method, params }) => { + expect(method).toBe('eth_sendTransaction'); + expect(params).toEqual([ + { + from: phoneWallet.address, + to: TX.to, + value: '0x5543df729c000', + chainId: '0x64', + data: '0xabcdef', + }, + ]); + return HASH; + }); + + await expect(backend.sendTransaction(TX)).resolves.toBe(HASH); + }); + + test('omits value/data defaults and rejects malformed hashes with REMOTE_BAD_RESPONSE', async () => { + mockRequestRemoteSignature.mockImplementation(async ({ params }) => { + expect(params[0].value).toBe('0x0'); + expect(params[0].data).toBeUndefined(); + return 'not-a-hash'; + }); + await expect(backend.sendTransaction({ to: TX.to, chainId: 100 })).rejects.toMatchObject({ + code: 'REMOTE_BAD_RESPONSE', + }); + }); +}); diff --git a/src/main/wallet/signers.js b/src/main/wallet/signers.js index bd8a19df..0c1d5192 100644 --- a/src/main/wallet/signers.js +++ b/src/main/wallet/signers.js @@ -11,7 +11,8 @@ * typed data → object) happens once in the factory, so backends always * receive the same shapes. The EIP-712 payload keeps its full dApp wire * shape (EIP712Domain in types) because backends genuinely diverge there: - * ethers wants the domain stripped, a Ledger app consumes the full payload. + * ethers wants the domain stripped, device backends (Ledger, phone) want + * the canonical wire payload — both conversions live in ./signing-utils.js. * * Error contract: vault-locked errors keep their identity (check with * `isVaultLockedError`); hardware backends must surface user-rejection @@ -23,6 +24,8 @@ const { Wallet, computeAddress } = require('ethers'); const { withVaultPrivateKey, isValidWalletIndex } = require('./vault-access'); const { getWalletRecord, WALLET_TYPES } = require('../identity-manager'); const { createLedgerBackend } = require('./ledger/signer'); +const { createRemoteBackend } = require('./remote/signer'); +const { withoutDomainType } = require('./signing-utils'); /** * @typedef {Object} Signer @@ -36,6 +39,11 @@ const { createLedgerBackend } = require('./ledger/signer'); * @property {(typedData: object) => Promise} signTypedData * EIP-712 signature; receives the parsed full payload * ({domain, types, message}, EIP712Domain in types allowed). + * @property {(tx: object) => Promise} [sendTransaction] + * Optional capability: sign AND broadcast through the backend's own + * channel, returning the tx hash. Present when the backend cannot + * produce a raw signed tx (phone wallets expose eth_sendTransaction + * only); callers must prefer it over signTransaction+broadcast. */ /** 0x-hex dApp messages are signatures over the bytes, not the hex text. */ @@ -70,9 +78,7 @@ function createVaultBackend(walletIndex) { try { const { domain, types, message } = typedData; // ethers computes the domain separator itself - const typesWithoutDomain = { ...types }; - delete typesWithoutDomain.EIP712Domain; - return await new Wallet(privateKey).signTypedData(domain, typesWithoutDomain, message); + return await new Wallet(privateKey).signTypedData(domain, withoutDomainType(types), message); } catch (err) { throw new Error(`Typed data signing failed: ${err.message}`, { cause: err }); } @@ -101,13 +107,17 @@ function getSigner(walletIndex) { // Unknown indexes fall through to the vault backend, which fails with // its own vault-derivation errors — the pre-hardware-wallet behaviour. const record = getWalletRecord(walletIndex); - const backend = - record && record.type === WALLET_TYPES.LEDGER - ? createLedgerBackend(record) - : createVaultBackend(walletIndex); + let backend; + if (record && record.type === WALLET_TYPES.LEDGER) { + backend = createLedgerBackend(record); + } else if (record && record.type === WALLET_TYPES.REMOTE) { + backend = createRemoteBackend(record); + } else { + backend = createVaultBackend(walletIndex); + } let address = null; - return { + const signer = { getAddress: async () => { if (address === null) { address = await backend.getAddress(); @@ -118,6 +128,13 @@ function getSigner(walletIndex) { signMessage: (message) => backend.signMessage(normalizeMessage(message)), signTypedData: (typedData) => backend.signTypedData(normalizeTypedData(typedData)), }; + // Optional capability: backends that sign AND broadcast through their + // own channel (phone wallets only expose eth_sendTransaction). Callers + // probe for it — see transaction-service's signAndSendTransaction. + if (typeof backend.sendTransaction === 'function') { + signer.sendTransaction = (tx) => backend.sendTransaction(tx); + } + return signer; } module.exports = { getSigner }; diff --git a/src/main/wallet/signers.test.js b/src/main/wallet/signers.test.js index 414a256e..bb0ad0bd 100644 --- a/src/main/wallet/signers.test.js +++ b/src/main/wallet/signers.test.js @@ -18,11 +18,19 @@ const mockLedgerBackend = { signTypedData: jest.fn(), }; const mockCreateLedgerBackend = jest.fn(() => mockLedgerBackend); +const mockRemoteBackend = { + getAddress: jest.fn(), + signTransaction: jest.fn(), + signMessage: jest.fn(), + signTypedData: jest.fn(), + sendTransaction: jest.fn(), +}; +const mockCreateRemoteBackend = jest.fn(() => mockRemoteBackend); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), getWalletRecord: (...args) => mockGetWalletRecord(...args), - WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger', REMOTE: 'remote' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, @@ -30,6 +38,9 @@ jest.mock('../vault-timer', () => ({ jest.mock('./ledger/signer', () => ({ createLedgerBackend: (...args) => mockCreateLedgerBackend(...args), })); +jest.mock('./remote/signer', () => ({ + createRemoteBackend: (...args) => mockCreateRemoteBackend(...args), +})); const { getSigner } = require('./signers'); @@ -182,4 +193,47 @@ describe('getSigner (ledger-backed dispatch)', () => { await signer.signTypedData('{"domain":{},"types":{},"message":{}}'); expect(mockLedgerBackend.signTypedData).toHaveBeenCalledWith({ domain: {}, types: {}, message: {} }); }); + + test('vault and ledger signers do not advertise the sendTransaction capability', () => { + expect(getSigner(2).sendTransaction).toBeUndefined(); + mockGetWalletRecord.mockReturnValue({ index: 0, name: 'Main Wallet', type: 'mnemonic' }); + expect(getSigner(0).sendTransaction).toBeUndefined(); + }); +}); + +describe('getSigner (remote-backed dispatch)', () => { + const REMOTE_RECORD = { + index: 4, + name: 'My Phone', + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + type: 'remote', + }; + + beforeEach(() => { + mockGetWalletRecord.mockReturnValue(REMOTE_RECORD); + mockRemoteBackend.getAddress.mockReset().mockResolvedValue(REMOTE_RECORD.address); + mockRemoteBackend.signMessage.mockReset().mockResolvedValue('0xsig'); + mockRemoteBackend.sendTransaction.mockReset().mockResolvedValue('0xhash'); + mockCreateRemoteBackend.mockClear(); + }); + + test('routes to the remote backend built from the wallet record, never the vault', async () => { + const signer = getSigner(4); + await expect(signer.getAddress()).resolves.toBe(REMOTE_RECORD.address); + expect(mockCreateRemoteBackend).toHaveBeenCalledWith(REMOTE_RECORD); + expect(mockIdentity.isUnlocked).not.toHaveBeenCalled(); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); + + test('advertises the backend sendTransaction capability', async () => { + const signer = getSigner(4); + await expect(signer.sendTransaction({ to: '0x1', chainId: 100 })).resolves.toBe('0xhash'); + expect(mockRemoteBackend.sendTransaction).toHaveBeenCalledWith({ to: '0x1', chainId: 100 }); + }); + + test('factory-level normalization applies to remote backends too', async () => { + const signer = getSigner(4); + await signer.signMessage('0x48656c6c6f'); + expect(mockRemoteBackend.signMessage).toHaveBeenCalledWith(Buffer.from('Hello', 'utf8')); + }); }); diff --git a/src/main/wallet/signing-utils.js b/src/main/wallet/signing-utils.js new file mode 100644 index 00000000..5a611a24 --- /dev/null +++ b/src/main/wallet/signing-utils.js @@ -0,0 +1,40 @@ +/** + * Input-shape helpers shared by the signer backends. + * + * EIP-712 payloads arrive in the full dApp wire shape (EIP712Domain in + * types); ethers' hashing helpers want the domain type stripped, while + * device backends (Ledger app, phone wallet RPC) want the canonical wire + * payload with EIP712Domain and primaryType restored. Both conversions + * live here so every backend signs the same canonical bytes. + */ + +const { TypedDataEncoder } = require('ethers'); + +/** Types with EIP712Domain stripped, as ethers' hashing helpers expect. */ +function withoutDomainType(types) { + const stripped = { ...types }; + delete stripped.EIP712Domain; + return stripped; +} + +/** + * The canonical EIP-712 wire payload plus the pieces backends verify + * with: getPayload reconstructs EIP712Domain in types and an explicit + * primaryType when ethers-style callers omitted them. + * + * @param {{domain?: object, types: object, message: object}} typedData + * @returns {{domain: object, strippedTypes: object, payload: object}} + */ +function getEip712WirePayload(typedData) { + const domain = typedData.domain || {}; + const strippedTypes = withoutDomainType(typedData.types); + const payload = TypedDataEncoder.getPayload(domain, strippedTypes, typedData.message); + return { domain, strippedTypes, payload }; +} + +/** EIP-191 personal messages sign over bytes; utf8-encode plain strings. */ +function messageToBytes(message) { + return Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8'); +} + +module.exports = { withoutDomainType, getEip712WirePayload, messageToBytes }; diff --git a/src/main/wallet/transaction-service.js b/src/main/wallet/transaction-service.js index 4ad64c40..8283e094 100644 --- a/src/main/wallet/transaction-service.js +++ b/src/main/wallet/transaction-service.js @@ -9,6 +9,7 @@ const { parseUnits, formatUnits, Interface } = require('ethers'); const { getProvider, withRetry } = require('./provider-manager'); const { getTxExplorerUrl } = require('./chains'); +const { REMOTE_ERROR_CODES, createRemoteError } = require('./remote/errors'); // ERC-20 transfer function interface const ERC20_INTERFACE = new Interface([ @@ -178,6 +179,30 @@ function buildTransaction({ return tx; } +/** + * Best-effort check that a device-broadcast tx really came from the + * signer's account: a compromised responder could report the hash of + * someone else's transaction. The tx usually reaches our RPC a beat + * after the device's, so "not visible yet" is not an error — only a + * visible mismatch is. + */ +async function verifyDeviceBroadcastFrom(provider, hash, expectedFrom, chainId) { + let tx; + try { + tx = await withRetry(() => provider.getTransaction(hash), 2, chainId); + } catch (err) { + console.warn('[TransactionService] Device-broadcast lookup failed:', err.message); + return; + } + if (!tx) { + console.warn('[TransactionService] Device-broadcast tx not visible on our RPC yet:', hash); + return; + } + if (tx.from.toLowerCase() !== expectedFrom.toLowerCase()) { + throw createRemoteError(REMOTE_ERROR_CODES.WRONG_ACCOUNT); + } +} + /** * Sign and broadcast a transaction. * @@ -208,6 +233,25 @@ async function signAndSendTransaction(params, signer) { try { const from = await signer.getAddress(); + // Backends that can only sign-and-broadcast through their own channel + // (phone wallets) expose the optional sendTransaction capability: the + // remote wallet picks the nonce, estimates gas, and broadcasts via its + // own RPC — our gas parameters would be stale guesses by the time the + // user confirms on the device, so only the intent fields go over. + if (typeof signer.sendTransaction === 'function') { + const hash = await signer.sendTransaction({ to, value, data, chainId }); + await verifyDeviceBroadcastFrom(provider, hash, from, chainId); + console.log('[TransactionService] Transaction broadcast by signer:', hash); + return { + hash, + from, + to, + value, + chainId, + explorerUrl: getTxExplorerUrl(chainId, hash), + }; + } + // Get nonce const nonce = await withRetry(() => provider.getTransactionCount(from, 'pending'), 2, chainId); @@ -249,6 +293,13 @@ async function signAndSendTransaction(params, signer) { } catch (err) { console.error('[TransactionService] Transaction failed:', err); + // Device-backend errors (LEDGER_*/REMOTE_*) carry a stable code and a + // user-facing message; rewrapping them here would strip the code and + // let the local-provider heuristics below mislabel them. + if (typeof err.code === 'string' && /^(LEDGER|REMOTE)_/.test(err.code)) { + throw err; + } + // Parse common error messages if (err.message.includes('insufficient funds')) { throw new Error('Insufficient funds for transaction', { cause: err }); diff --git a/src/main/wallet/transaction-service.test.js b/src/main/wallet/transaction-service.test.js index 6ae48d95..97f97ab1 100644 --- a/src/main/wallet/transaction-service.test.js +++ b/src/main/wallet/transaction-service.test.js @@ -88,6 +88,90 @@ describe('signAndSendTransaction (signer-based)', () => { ).rejects.toThrow('Insufficient funds for transaction'); }); + describe('signer sendTransaction capability (device broadcasts itself)', () => { + // Remote (phone) signers cannot produce a raw signed tx: the phone + // picks the nonce and broadcasts itself. Only intent fields go over. + const hash = '0x' + 'cd'.repeat(32); + const params = { + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + data: '0xabcdef', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + chainId: 8453, + }; + let getTransaction; + let broadcastingSigner; + + beforeEach(() => { + getTransaction = jest.fn(async () => ({ from: testWallet.address })); + mockGetProvider.mockReturnValue({ getTransaction }); + broadcastingSigner = { ...signer, sendTransaction: jest.fn(async () => hash) }; + }); + + it('prefers the capability over sign+broadcast and verifies the tx sender', async () => { + const result = await signAndSendTransaction(params, broadcastingSigner); + + expect(broadcastingSigner.sendTransaction).toHaveBeenCalledWith({ + to: params.to, + value: '1000', + data: '0xabcdef', + chainId: 8453, + }); + expect(broadcastedRaw).toBeNull(); // never touched our provider's broadcast path + expect(getTransaction).toHaveBeenCalledWith(hash); + expect(result).toEqual({ + hash, + from: testWallet.address, + to: params.to, + value: '1000', + chainId: 8453, + explorerUrl: `https://explorer.test/8453/${hash}`, + }); + }); + + it('rejects with REMOTE_WRONG_ACCOUNT when the broadcast tx is from someone else', async () => { + getTransaction.mockResolvedValue({ from: '0x' + '11'.repeat(20) }); + await expect(signAndSendTransaction(params, broadcastingSigner)).rejects.toMatchObject({ + code: 'REMOTE_WRONG_ACCOUNT', + }); + }); + + it('tolerates a tx not yet visible on our RPC (best-effort check)', async () => { + getTransaction.mockResolvedValue(null); + await expect(signAndSendTransaction(params, broadcastingSigner)).resolves.toMatchObject({ hash }); + }); + + it('tolerates lookup failures (best-effort check)', async () => { + getTransaction.mockRejectedValue(new Error('rpc down')); + await expect(signAndSendTransaction(params, broadcastingSigner)).resolves.toMatchObject({ hash }); + }); + }); + + it('passes device-backend errors through with their stable code intact', async () => { + // LEDGER_*/REMOTE_* errors carry user-facing messages and codes the + // approval UIs rely on; the generic rewrap must not swallow them — + // e.g. a phone error mentioning "gas" must not become our local + // gas-estimation message. + const deviceError = Object.assign(new Error('intrinsic gas too low'), { + code: 'REMOTE_UNKNOWN', + }); + const rejectingSigner = { + ...signer, + signTransaction: async () => { + throw deviceError; + }, + }; + + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', gasPrice: '7', chainId: 1 }, + rejectingSigner, + ), + ).rejects.toBe(deviceError); + }); + it('surfaces signer rejection (e.g. user declined on device) unchanged', async () => { const decliningSigner = { ...signer, diff --git a/src/main/wallet/vault-access.js b/src/main/wallet/vault-access.js index 969d0f2b..da3da99e 100644 --- a/src/main/wallet/vault-access.js +++ b/src/main/wallet/vault-access.js @@ -55,11 +55,11 @@ async function withVaultPrivateKey(walletIndex, callback) { throw new Error('Invalid wallet index'); } // Hard stop for non-mnemonic accounts at the key-derivation chokepoint: - // deriving a mnemonic key at a hardware account's index would silently - // sign with a key whose address the user has never seen. + // deriving a mnemonic key at a hardware/phone account's index would + // silently sign with a key whose address the user has never seen. const record = getWalletRecord(walletIndex); if (record && record.type !== WALLET_TYPES.MNEMONIC) { - throw new Error('Hardware wallet accounts have no vault key; sign via their device signer'); + throw new Error('This account keeps its key on another device; sign via its device signer'); } const identity = await loadIdentityModule(); if (!identity.isUnlocked()) { diff --git a/src/main/wallet/vault-access.test.js b/src/main/wallet/vault-access.test.js index 9ce62d8d..0a46c983 100644 --- a/src/main/wallet/vault-access.test.js +++ b/src/main/wallet/vault-access.test.js @@ -72,12 +72,15 @@ describe('withVaultPrivateKey', () => { expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(3); }); - test('refuses to derive a vault key for a hardware-wallet index', async () => { + test.each([ + ['ledger', '0xstax'], + ['remote', '0xphone'], + ])('refuses to derive a vault key for a %s account index', async (type, address) => { // The chokepoint guard: even a caller that bypasses the signer - // factory must never get a mnemonic key at a ledger account's index. - mockGetWalletRecord.mockReturnValue({ index: 3, type: 'ledger', address: '0xstax' }); + // factory must never get a mnemonic key at a device account's index. + mockGetWalletRecord.mockReturnValue({ index: 3, type, address }); await expect(withVaultPrivateKey(3, () => 'unreachable')) - .rejects.toThrow('Hardware wallet accounts have no vault key'); + .rejects.toThrow('This account keeps its key on another device'); expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 7d77c844..dbc09ef8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Freedom diff --git a/src/renderer/lib/wallet-ui.js b/src/renderer/lib/wallet-ui.js index 6f178dc1..9901348a 100644 --- a/src/renderer/lib/wallet-ui.js +++ b/src/renderer/lib/wallet-ui.js @@ -27,6 +27,7 @@ import { initReceive, closeReceive } from './wallet/receive.js'; import { initWalletSettings, closeWalletSettings } from './wallet/wallet-settings.js'; import { initCreateWallet, openCreateWallet, closeCreateWallet } from './wallet/create-wallet.js'; import { initConnectLedger, openConnectLedger, closeConnectLedger } from './wallet/connect-ledger.js'; +import { initRemoteSession } from './wallet/remote-session.js'; import { initPublishSetup, openPublishSetup, closePublishSetup } from './wallet/publish-setup.js'; import { initStampManager, closeStampManager } from './wallet/stamp-manager.js'; import { initChequebookDeposit, closeChequebookDeposit } from './wallet/chequebook-deposit.js'; @@ -92,6 +93,7 @@ export function initWalletUi() { initWalletSettings(switchTab); initCreateWallet(); initConnectLedger(); + initRemoteSession(); initPublishSetup(); initStampManager(); initChequebookDeposit(); diff --git a/src/renderer/lib/wallet/remote-session.js b/src/renderer/lib/wallet/remote-session.js new file mode 100644 index 00000000..3ac8891e --- /dev/null +++ b/src/renderer/lib/wallet/remote-session.js @@ -0,0 +1,202 @@ +/** + * Remote-signer session broker. + * + * Main's remote signer backend publishes signing jobs over IPC (see + * src/main/wallet/remote/bridge.js); this module runs them: it hosts an + * openlv session (P2P, end-to-end encrypted — the signaling relay only + * sees ciphertext), exposes the session URI for the QR dialog, tunnels + * the wallet JSON-RPC request to the phone, and responds to main with + * the phone's answer. Main verifies every signature before using it — + * nothing here is trusted with more than transporting bytes. + * + * One session per job (per-request QR): no session secrets outlive the + * request. UI (WP-R3) subscribes via onJobEvent to render QR / waiting / + * error states and calls cancelJob when the user closes the dialog. + */ + +// Default signaling relay (spec default). Carries only encrypted +// handshake/negotiation frames — never wallet data in the clear. +const DEFAULT_SIGNALING = { p: 'mqtt', s: 'wss://test.mosquitto.org:8081/mqtt' }; + +// Where the dual-purpose QR sends phones without an openlv-native wallet. +// The session secret rides in the URL *fragment*, which browsers never +// send to the server. PLACEHOLDER until the bridge page ships (WP-R3.5). +const BRIDGE_ORIGIN = 'https://connect.freedom.baby'; + +// The openlv SDK (168 KiB vendor bundle) is only needed once a signing +// job actually arrives — keep it off the renderer boot path. +let vendorPromise = null; +const loadVendorOpenlv = () => (vendorPromise ??= import('../../vendor/openlv.esm.js')); + +/** Wallet SDKs answer `{result}` / `{error:{code,message}}` envelopes; tolerate bare values. */ +function unwrapResponse(payload) { + if (payload && typeof payload === 'object') { + if (payload.error) return { error: payload.error }; + if ('result' in payload) return { result: payload.result }; + } + return { result: payload }; +} + +/** + * @param {Object} deps + * @param {Object} [deps.openlv] - openlv SDK surface (injectable for tests; lazy vendor load otherwise) + * @param {typeof window.remoteSigner} [deps.remoteSigner] - preload IPC bridge + * @param {{p: string, s: string}} [deps.signaling] + * @param {string} [deps.bridgeOrigin] + */ +export function createRemoteSessionBroker({ + openlv = null, + remoteSigner = window.remoteSigner, + signaling = DEFAULT_SIGNALING, + bridgeOrigin = BRIDGE_ORIGIN, +} = {}) { + /** jobId → { session, settled } */ + const jobs = new Map(); + const listeners = new Set(); + const disposers = []; + + function emit(event) { + for (const listener of listeners) { + try { + listener(event); + } catch (err) { + console.error('[RemoteSession] job listener failed:', err); + } + } + } + + /** Settle a job exactly once: respond to main (unless silent) and tear down. */ + function settle(jobId, payload) { + const job = jobs.get(jobId); + if (!job || job.settled) return false; + job.settled = true; + if (payload) { + remoteSigner.respond({ jobId, ...payload }); + } + teardown(jobId); + return true; + } + + function teardown(jobId) { + const job = jobs.get(jobId); + jobs.delete(jobId); + if (job?.session) { + Promise.resolve(job.session.close()).catch((err) => { + console.warn('[RemoteSession] session close failed:', err.message); + }); + } + } + + // The phone should never initiate requests toward the browser in this + // flow; answer like the reference provider does so openlv-SDK wallets + // get a proper JSON-RPC error instead of a hang. + const onIncomingMessage = async () => ({ error: { code: -32601, message: 'Method not found' } }); + + async function runJob(job) { + const { jobId, method, params } = job; + if (jobs.has(jobId)) return; + const entry = { session: null, settled: false }; + jobs.set(jobId, entry); + + try { + const sdk = openlv || (await loadVendorOpenlv()); + const session = await sdk.createSession( + signaling, + sdk.mqtt, + [sdk.webrtc()], + onIncomingMessage, + ); + if (entry.settled) { + // Cancelled/aborted while the session was being created. + Promise.resolve(session.close()).catch(() => {}); + return; + } + entry.session = session; + + const uri = sdk.encodeConnectionURL(session.getHandshakeParameters()); + emit({ + jobId, + phase: 'qr', + method, + uri, + bridgeUrl: `${bridgeOrigin}/#${uri}`, + }); + + session.emitter.on('state_change', (state) => { + if (!entry.settled && state?.status) { + emit({ jobId, phase: state.status, method }); + } + }); + + await session.connect(); + await session.waitForLink(); + if (entry.settled) return; + + emit({ jobId, phase: 'awaiting-approval', method }); + const { result, error } = unwrapResponse(await session.send({ method, params })); + + if (error) { + emit({ jobId, phase: 'error', method, error }); + // rpcCode: main maps EIP-1193 codes (4001 …) to REMOTE_* there, + // where the error registry lives. + settle(jobId, { error: { rpcCode: error.code, message: error.message } }); + } else { + emit({ jobId, phase: 'done', method }); + settle(jobId, { result }); + } + } catch (err) { + console.error('[RemoteSession] job failed:', err); + emit({ jobId, phase: 'error', method, error: { message: err.message } }); + settle(jobId, { error: { code: 'REMOTE_UNKNOWN', message: err.message } }); + } + } + + return { + /** Start listening for signing jobs from main. */ + start() { + disposers.push(remoteSigner.onRequest((job) => runJob(job))); + disposers.push( + remoteSigner.onAbort(({ jobId }) => { + // Main already failed the job (timeout) — settle silently. + if (settle(jobId)) { + emit({ jobId, phase: 'aborted' }); + } + }), + ); + }, + + stop() { + while (disposers.length) disposers.pop()(); + for (const jobId of [...jobs.keys()]) teardown(jobId); + }, + + /** User closed the QR dialog — tell main and drop the session. */ + cancelJob(jobId) { + if (settle(jobId, { error: { code: 'REMOTE_USER_CANCELLED' } })) { + emit({ jobId, phase: 'cancelled' }); + } + }, + + /** Subscribe to job lifecycle events; returns a disposer. */ + onJobEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +let defaultBroker = null; + +/** Wire the singleton broker to the preload bridge (called from wallet-ui). */ +export function initRemoteSession() { + if (!defaultBroker) { + defaultBroker = createRemoteSessionBroker(); + defaultBroker.start(); + } + return defaultBroker; +} + +/** The running broker, for UI modules (QR dialog) to subscribe/cancel. */ +export function getRemoteSessionBroker() { + return defaultBroker; +} diff --git a/src/renderer/lib/wallet/remote-session.test.js b/src/renderer/lib/wallet/remote-session.test.js new file mode 100644 index 00000000..9946c26f --- /dev/null +++ b/src/renderer/lib/wallet/remote-session.test.js @@ -0,0 +1,231 @@ +/** + * Broker unit tests with a scripted openlv stack: what matters here is + * the job protocol against main (respond exactly once, cancel/abort + * semantics) and the event stream the QR dialog will consume. Real + * protocol round-trips live in the openlv integration test. + */ + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createFakeSession() { + const link = deferred(); + const response = deferred(); + return { + link, + response, + connect: jest.fn(async () => {}), + waitForLink: jest.fn(() => link.promise), + send: jest.fn(() => response.promise), + close: jest.fn(async () => {}), + getHandshakeParameters: () => ({ + version: 1, + sessionId: 'abcdef1234567890', + h: '00ff00ff00ff00ff', + k: '11'.repeat(16), + p: 'mqtt', + s: 'wss://relay.test/mqtt', + }), + emitter: { on: jest.fn(), off: jest.fn() }, + }; +} + +function createHarness() { + const session = createFakeSession(); + const openlv = { + createSession: jest.fn(async () => session), + mqtt: 'MQTT_LAYER', + webrtc: jest.fn(() => 'WEBRTC_LAYER'), + encodeConnectionURL: jest.fn( + (p) => `openlv://${p.sessionId}@1?h=${p.h}&k=${p.k}&p=${p.p}&s=${encodeURIComponent(p.s)}`, + ), + }; + const handlers = {}; + const remoteSigner = { + onRequest: jest.fn((cb) => { + handlers.request = cb; + return () => delete handlers.request; + }), + onAbort: jest.fn((cb) => { + handlers.abort = cb; + return () => delete handlers.abort; + }), + respond: jest.fn(), + }; + return { session, openlv, remoteSigner, handlers }; +} + +async function startBroker(harness) { + const { createRemoteSessionBroker } = await import('./remote-session.js'); + const broker = createRemoteSessionBroker({ + openlv: harness.openlv, + remoteSigner: harness.remoteSigner, + bridgeOrigin: 'https://bridge.test', + }); + broker.start(); + return broker; +} + +const JOB = { jobId: 'job-1', walletIndex: 4, address: '0xabc', method: 'personal_sign', params: ['0x6869', '0xabc'] }; + +// Let the runJob microtasks settle. +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('remote-session broker', () => { + test('runs a job end to end: session, QR event, request, response to main', async () => { + const harness = createHarness(); + const broker = await startBroker(harness); + const events = []; + broker.onJobEvent((e) => events.push(e)); + + harness.handlers.request(JOB); + await tick(); + + // Host session created over the injected signaling/transport layers. + expect(harness.openlv.createSession).toHaveBeenCalledWith( + expect.objectContaining({ p: 'mqtt' }), + 'MQTT_LAYER', + ['WEBRTC_LAYER'], + expect.any(Function), + ); + + // QR event exposes the raw URI and the fragment-carrying bridge URL. + const qrEvent = events.find((e) => e.phase === 'qr'); + expect(qrEvent.uri).toMatch(/^openlv:\/\/abcdef1234567890@1\?/); + expect(qrEvent.bridgeUrl).toBe(`https://bridge.test/#${qrEvent.uri}`); + expect(qrEvent.method).toBe('personal_sign'); + + harness.session.link.resolve(); + await tick(); + expect(harness.session.send).toHaveBeenCalledWith({ + method: 'personal_sign', + params: ['0x6869', '0xabc'], + }); + expect(events.some((e) => e.phase === 'awaiting-approval')).toBe(true); + + harness.session.response.resolve({ result: '0xsignature' }); + await tick(); + expect(harness.remoteSigner.respond).toHaveBeenCalledWith({ jobId: 'job-1', result: '0xsignature' }); + expect(harness.session.close).toHaveBeenCalled(); + }); + + test('treats a bare (non-envelope) payload as the result', async () => { + const harness = createHarness(); + await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + harness.session.link.resolve(); + await tick(); + harness.session.response.resolve('0xbaresig'); + await tick(); + + expect(harness.remoteSigner.respond).toHaveBeenCalledWith({ jobId: 'job-1', result: '0xbaresig' }); + }); + + test('forwards phone JSON-RPC errors to main as rpcCode', async () => { + const harness = createHarness(); + await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + harness.session.link.resolve(); + await tick(); + harness.session.response.resolve({ error: { code: 4001, message: 'User denied.' } }); + await tick(); + + expect(harness.remoteSigner.respond).toHaveBeenCalledWith({ + jobId: 'job-1', + error: { rpcCode: 4001, message: 'User denied.' }, + }); + }); + + test('reports session failures as REMOTE_UNKNOWN with the underlying message', async () => { + const harness = createHarness(); + await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + harness.session.link.reject(new Error('Session failed to connect')); + await tick(); + + expect(harness.remoteSigner.respond).toHaveBeenCalledWith({ + jobId: 'job-1', + error: { code: 'REMOTE_UNKNOWN', message: 'Session failed to connect' }, + }); + expect(harness.session.close).toHaveBeenCalled(); + }); + + test('cancelJob responds USER_CANCELLED once and ignores a late phone answer', async () => { + const harness = createHarness(); + const broker = await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + broker.cancelJob('job-1'); + + expect(harness.remoteSigner.respond).toHaveBeenCalledWith({ + jobId: 'job-1', + error: { code: 'REMOTE_USER_CANCELLED' }, + }); + expect(harness.session.close).toHaveBeenCalled(); + + // Phone answers after the user already cancelled — must not respond again. + harness.session.link.resolve(); + harness.session.response.resolve({ result: '0xlate' }); + await tick(); + expect(harness.remoteSigner.respond).toHaveBeenCalledTimes(1); + }); + + test('abort from main tears the session down without responding', async () => { + const harness = createHarness(); + const broker = await startBroker(harness); + const events = []; + broker.onJobEvent((e) => events.push(e)); + + harness.handlers.request(JOB); + await tick(); + harness.handlers.abort({ jobId: 'job-1' }); + + expect(harness.session.close).toHaveBeenCalled(); + expect(harness.remoteSigner.respond).not.toHaveBeenCalled(); + expect(events.some((e) => e.phase === 'aborted')).toBe(true); + + // A phone answer for the aborted job must not reach main either. + harness.session.link.resolve(); + harness.session.response.resolve({ result: '0xlate' }); + await tick(); + expect(harness.remoteSigner.respond).not.toHaveBeenCalled(); + }); + + test('answers unexpected phone-initiated requests with method-not-found', async () => { + const harness = createHarness(); + await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + const onIncoming = harness.openlv.createSession.mock.calls[0][3]; + await expect(onIncoming({ method: 'eth_accounts' })).resolves.toEqual({ + error: { code: -32601, message: 'Method not found' }, + }); + }); + + test('stop() unsubscribes and closes live sessions', async () => { + const harness = createHarness(); + const broker = await startBroker(harness); + + harness.handlers.request(JOB); + await tick(); + broker.stop(); + + expect(harness.handlers.request).toBeUndefined(); + expect(harness.handlers.abort).toBeUndefined(); + expect(harness.session.close).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/vendor/openlv.esm.js b/src/renderer/vendor/openlv.esm.js new file mode 100644 index 00000000..d4538422 --- /dev/null +++ b/src/renderer/vendor/openlv.esm.js @@ -0,0 +1,8 @@ +/*! + * openlv.esm.js — bundled from @openlv/session@0.0.3 and its + * dependencies (https://github.com/v3xlabs/open-lavatory, LGPL-3.0-only). + * Generated by scripts/bundle-openlv.js — do not edit by hand. + */ +var as=Object.create;var nr=Object.defineProperty;var fs=Object.getOwnPropertyDescriptor;var us=Object.getOwnPropertyNames;var ls=Object.getPrototypeOf,ds=Object.prototype.hasOwnProperty;var R=(t,e,n)=>()=>{if(n)throw n[0];try{return t&&(e=t(t=0)),e}catch(r){throw n=[r],r}};var hs=(t,e)=>()=>{try{return e||t((e={exports:{}}).exports,e),e.exports}catch(n){throw e=0,n}};var ps=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of us(e))!ds.call(t,o)&&o!==n&&nr(t,o,{get:()=>e[o],enumerable:!(r=fs(e,o))||r.enumerable});return t};var ys=(t,e,n)=>(n=t!=null?as(ls(t)):{},ps(e||!t||!t.__esModule?nr(n,"default",{value:t,enumerable:!0}):n,t));var rr,or=R(()=>{rr=(t,e)=>Object.assign(t,e)});var sr=R(()=>{});var ir=R(()=>{});var cr,gs,xs,ar=R(()=>{cr=1,gs=t=>{let e=new URLSearchParams;return e.set("h",t.h),e.set("k",t.k),t.p&&e.set("p",t.p),t.s&&e.set("s",t.s),`openlv://${t.sessionId}@1?${e.toString()}`},xs=t=>{if(typeof t!="string")throw new TypeError(`Invalid URL type: expected string, got ${typeof t}`);if(!t||t.trim()==="")throw new Error("URL cannot be empty");if(!t.startsWith("openlv://"))throw new Error(`Invalid URL format: must start with 'openlv://', got: ${t}`);try{let e=new URL(t),n=e.username,r=Number(e.hostname||e.pathname.replace("/",""));if(r!==1)throw new Error(`Invalid protocol version: expected 1, got ${r}`);let o=e.searchParams.get("h")||"",s=e.searchParams.get("k")||"",i=e.searchParams.get("s")||void 0,a=e.searchParams.get("p")||"mqtt";if(!n)throw new Error("Session ID is required in URL");if(!/^[A-Za-z0-9_-]{16}$/.test(n))throw new Error("Invalid session ID format: must be 16 URL-safe characters");if(!o)throw new Error("Public key hash (h parameter) is required in URL");if(!/^[0-9a-f]{16}$/.test(o))throw new Error("Invalid public key hash format: must be 16 hex characters");if(!s)throw new Error("Shared key (k parameter) is required in URL");if(!/^[0-9a-f]{32}$/.test(s))throw new Error("Invalid shared key format: must be 32 lowercase hex characters");return{version:r,sessionId:n,h:o,k:s,s:i?decodeURIComponent(i):"",p:a}}catch(e){let n=e instanceof Error?e.message:"Unknown error",r=t.includes("?")?t.slice(0,t.indexOf("?"))+"?[redacted]":t;throw new Error(`Failed to parse URL: ${r}. Original error: ${n}`)}}});var ze=R(()=>{or();sr();ir();ar()});function fr(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function Qe(t){if(typeof t!="boolean")throw new Error(`boolean expected, not ${t}`)}function be(t){if(!Number.isSafeInteger(t)||t<0)throw new Error("positive integer expected, got "+t)}function Y(t,...e){if(!fr(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function Je(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function ur(t,e){Y(t);let n=e.outputLen;if(t.length{/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */ms=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;hr=(t,e)=>{function n(r,...o){if(Y(r),!ms)throw new Error("Non little-endian hardware is not yet supported");if(t.nonceLength!==void 0){let l=o[0];if(!l)throw new Error("nonce / iv required");t.varSizeNonce?Y(l):Y(l,t.nonceLength)}let s=t.tagLength;s&&o[1]!==void 0&&Y(o[1]);let i=e(r,...o),a=(l,d)=>{if(d!==void 0){if(l!==2)throw new Error("cipher output not supported");Y(d)}},c=!1;return{encrypt(l,d){if(c)throw new Error("cannot encrypt() twice with same key + nonce");return c=!0,Y(l),a(i.encrypt.length,d),i.encrypt(l,d)},decrypt(l,d){if(Y(l),s&&l.length{Y(a),Y(c),Y(u);let f=u.length;if(l===void 0&&(l=new Uint8Array(f)),Y(l),be(d),d<0||d>=gr)throw new Error("arx: counter overflow");if(l.length{Be();yr=t=>Uint8Array.from(t.split("").map(e=>e.charCodeAt(0))),Es=yr("expand 16-byte k"),Ss=yr("expand 32-byte k"),Bs=Et(Es),As=Et(Ss);Ae=64,vs=16,gr=2**32-1,pr=new Uint32Array});function Cs(t){let e=(r,o)=>t(o).update(Ee(r)).digest(),n=t(new Uint8Array(32));return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=r=>t(r),e}var W,nn,rn,wr=R(()=>{Be();W=(t,e)=>t[e++]&255|(t[e++]&255)<<8,nn=class{constructor(e){this.blockLen=16,this.outputLen=16,this.buffer=new Uint8Array(16),this.r=new Uint16Array(10),this.h=new Uint16Array(10),this.pad=new Uint16Array(8),this.pos=0,this.finished=!1,e=Ee(e),Y(e,32);let n=W(e,0),r=W(e,2),o=W(e,4),s=W(e,6),i=W(e,8),a=W(e,10),c=W(e,12),u=W(e,14);this.r[0]=n&8191,this.r[1]=(n>>>13|r<<3)&8191,this.r[2]=(r>>>10|o<<6)&7939,this.r[3]=(o>>>7|s<<9)&8191,this.r[4]=(s>>>4|i<<12)&255,this.r[5]=i>>>1&8190,this.r[6]=(i>>>14|a<<2)&8191,this.r[7]=(a>>>11|c<<5)&8065,this.r[8]=(c>>>8|u<<8)&8191,this.r[9]=u>>>5&127;for(let l=0;l<8;l++)this.pad[l]=W(e,16+2*l)}process(e,n,r=!1){let o=r?0:2048,{h:s,r:i}=this,a=i[0],c=i[1],u=i[2],l=i[3],d=i[4],f=i[5],p=i[6],y=i[7],w=i[8],x=i[9],h=W(e,n+0),m=W(e,n+2),g=W(e,n+4),v=W(e,n+6),_=W(e,n+8),E=W(e,n+10),B=W(e,n+12),O=W(e,n+14),b=s[0]+(h&8191),S=s[1]+((h>>>13|m<<3)&8191),N=s[2]+((m>>>10|g<<6)&8191),L=s[3]+((g>>>7|v<<9)&8191),A=s[4]+((v>>>4|_<<12)&8191),C=s[5]+(_>>>1&8191),U=s[6]+((_>>>14|E<<2)&8191),D=s[7]+((E>>>11|B<<5)&8191),H=s[8]+((B>>>8|O<<8)&8191),P=s[9]+(O>>>5|o),I=0,q=I+b*a+S*(5*x)+N*(5*w)+L*(5*y)+A*(5*p);I=q>>>13,q&=8191,q+=C*(5*f)+U*(5*d)+D*(5*l)+H*(5*u)+P*(5*c),I+=q>>>13,q&=8191;let j=I+b*c+S*a+N*(5*x)+L*(5*w)+A*(5*y);I=j>>>13,j&=8191,j+=C*(5*p)+U*(5*f)+D*(5*d)+H*(5*l)+P*(5*u),I+=j>>>13,j&=8191;let k=I+b*u+S*c+N*a+L*(5*x)+A*(5*w);I=k>>>13,k&=8191,k+=C*(5*y)+U*(5*p)+D*(5*f)+H*(5*d)+P*(5*l),I+=k>>>13,k&=8191;let z=I+b*l+S*u+N*c+L*a+A*(5*x);I=z>>>13,z&=8191,z+=C*(5*w)+U*(5*y)+D*(5*p)+H*(5*f)+P*(5*d),I+=z>>>13,z&=8191;let ut=I+b*d+S*l+N*u+L*c+A*a;I=ut>>>13,ut&=8191,ut+=C*(5*x)+U*(5*w)+D*(5*y)+H*(5*p)+P*(5*f),I+=ut>>>13,ut&=8191;let lt=I+b*f+S*d+N*l+L*u+A*c;I=lt>>>13,lt&=8191,lt+=C*a+U*(5*x)+D*(5*w)+H*(5*y)+P*(5*p),I+=lt>>>13,lt&=8191;let dt=I+b*p+S*f+N*d+L*l+A*u;I=dt>>>13,dt&=8191,dt+=C*c+U*a+D*(5*x)+H*(5*w)+P*(5*y),I+=dt>>>13,dt&=8191;let ht=I+b*y+S*p+N*f+L*d+A*l;I=ht>>>13,ht&=8191,ht+=C*u+U*c+D*a+H*(5*x)+P*(5*w),I+=ht>>>13,ht&=8191;let wt=I+b*w+S*y+N*p+L*f+A*d;I=wt>>>13,wt&=8191,wt+=C*l+U*u+D*c+H*a+P*(5*x),I+=wt>>>13,wt&=8191;let bt=I+b*x+S*w+N*y+L*p+A*f;I=bt>>>13,bt&=8191,bt+=C*d+U*l+D*u+H*c+P*a,I+=bt>>>13,bt&=8191,I=(I<<2)+I|0,I=I+q|0,q=I&8191,I=I>>>13,j+=I,s[0]=q,s[1]=j,s[2]=k,s[3]=z,s[4]=ut,s[5]=lt,s[6]=dt,s[7]=ht,s[8]=wt,s[9]=bt}finalize(){let{h:e,pad:n}=this,r=new Uint16Array(10),o=e[1]>>>13;e[1]&=8191;for(let a=2;a<10;a++)e[a]+=o,o=e[a]>>>13,e[a]&=8191;e[0]+=o*5,o=e[0]>>>13,e[0]&=8191,e[1]+=o,o=e[1]>>>13,e[1]&=8191,e[2]+=o,r[0]=e[0]+5,o=r[0]>>>13,r[0]&=8191;for(let a=1;a<10;a++)r[a]=e[a]+o,o=r[a]>>>13,r[a]&=8191;r[9]-=8192;let s=(o^1)-1;for(let a=0;a<10;a++)r[a]&=s;s=~s;for(let a=0;a<10;a++)e[a]=e[a]&s|r[a];e[0]=(e[0]|e[1]<<13)&65535,e[1]=(e[1]>>>3|e[2]<<10)&65535,e[2]=(e[2]>>>6|e[3]<<7)&65535,e[3]=(e[3]>>>9|e[4]<<4)&65535,e[4]=(e[4]>>>12|e[5]<<1|e[6]<<14)&65535,e[5]=(e[6]>>>2|e[7]<<11)&65535,e[6]=(e[7]>>>5|e[8]<<8)&65535,e[7]=(e[8]>>>8|e[9]<<5)&65535;let i=e[0]+n[0];e[0]=i&65535;for(let a=1;a<8;a++)i=(e[a]+n[a]|0)+(i>>>16)|0,e[a]=i&65535;pt(r)}update(e){Je(this),e=Ee(e),Y(e);let{buffer:n,blockLen:r}=this,o=e.length;for(let s=0;s>>0,e[s++]=r[i]>>>8;return e}digest(){let{buffer:e,outputLen:n}=this;this.digestInto(e);let r=e.slice(0,n);return this.destroy(),r}};rn=Cs(t=>new nn(t))});function Is(t,e,n,r,o,s=20){let i=t[0],a=e[0],c=e[1],u=e[2],l=e[3],d=t[1],f=n[0],p=n[1],y=o,w=0,x=t[2],h=e[4],m=e[5],g=e[6],v=e[7],_=t[3],E=i,B=a,O=c,b=u,S=l,N=d,L=f,A=p,C=y,U=w,D=x,H=h,P=m,I=g,q=v,j=_;for(let z=0;z{mr();wr();Be();on=xr(Is,{counterRight:!0,extendNonceFn:Ts}),Ns=hr({blockSize:64,nonceLength:24,tagLength:16},(t,e)=>({encrypt(n,r){r=tn(n.length+32,r,!1);let o=r.subarray(0,32),s=r.subarray(32);r.set(n,32),pt(o),on(t,e,r,r);let i=rn(s,o);return r.set(i,16),pt(r.subarray(0,16),i),r.subarray(16)},decrypt(n,r){Y(n),r=tn(n.length+32,r,!1);let o=r.subarray(0,32),s=r.subarray(32,48),i=r.subarray(48);r.set(n,32),pt(o);let a=on(t,e,o,o),c=rn(i,a);if(!dr(s,c))throw new Error("invalid tag");return on(t,e,r.subarray(16),r.subarray(16)),pt(o,s,c),i}}))});var Mt,Er=R(()=>{Mt=typeof globalThis=="object"&&"crypto"in globalThis?globalThis.crypto:void 0});function qt(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function cn(t){if(!Number.isSafeInteger(t)||t<0)throw new Error("positive integer expected, got "+t)}function rt(t,...e){if(!qt(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function an(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function Br(t,e){rt(t);let n=e.outputLen;if(t.length=St._0&&t<=St._9)return t-St._0;if(t>=St.A&&t<=St.F)return t-(St.A-10);if(t>=St.a&&t<=St.f)return t-(St.a-10)}function Ce(t){if(typeof t!="string")throw new Error("hex string expected, got "+typeof t);if(Ar)return Uint8Array.fromHex(t);let e=t.length,n=e/2;if(e%2)throw new Error("hex string expected, got unpadded hex of length "+e);let r=new Uint8Array(n);for(let o=0,s=0;ot().update(un(r)).digest(),n=t();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>t(),e}function se(t=32){if(Mt&&typeof Mt.getRandomValues=="function")return Mt.getRandomValues(new Uint8Array(t));if(Mt&&typeof Mt.randomBytes=="function")return Uint8Array.from(Mt.randomBytes(t));throw new Error("crypto.getRandomValues must be defined")}var Ar,Os,St,ve,Xt=R(()=>{Er();/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */Ar=typeof Uint8Array.from([]).toHex=="function"&&typeof Uint8Array.fromHex=="function",Os=Array.from({length:256},(t,e)=>e.toString(16).padStart(2,"0"));St={_0:48,_9:57,A:65,F:70,a:97,f:102};ve=class{}});function Ls(t,e,n,r){if(typeof t.setBigUint64=="function")return t.setBigUint64(e,n,r);let o=BigInt(32),s=BigInt(4294967295),i=Number(n>>o&s),a=Number(n&s),c=r?4:0,u=r?0:4;t.setUint32(e+c,i,r),t.setUint32(e+u,a,r)}var Ie,X,_r=R(()=>{Xt();Ie=class extends ve{constructor(e,n,r,o){super(),this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.blockLen=e,this.outputLen=n,this.padOffset=r,this.isLE=o,this.buffer=new Uint8Array(e),this.view=_e(this.buffer)}update(e){an(this),e=un(e),rt(e);let{view:n,buffer:r,blockLen:o}=this,s=e.length;for(let i=0;io-i&&(this.process(r,0),i=0);for(let d=i;dl.length)throw new Error("_sha2: outputLen bigger than state");for(let d=0;d>Cr&Te)}:{h:Number(t>>Cr&Te)|0,l:Number(t&Te)|0}}function Ir(t,e=!1){let n=t.length,r=new Uint32Array(n),o=new Uint32Array(n);for(let s=0;s>>0)+(r>>>0);return{h:t+n+(o/2**32|0)|0,l:o|0}}var Te,Cr,ln,dn,jt,Vt,ie,ce,Tr,Nr,Or,Lr,Ur,Rr,Dr=R(()=>{Te=BigInt(4294967295),Cr=BigInt(32);ln=(t,e,n)=>t>>>n,dn=(t,e,n)=>t<<32-n|e>>>n,jt=(t,e,n)=>t>>>n|e<<32-n,Vt=(t,e,n)=>t<<32-n|e>>>n,ie=(t,e,n)=>t<<64-n|e>>>n-32,ce=(t,e,n)=>t>>>n-32|e<<64-n;Tr=(t,e,n)=>(t>>>0)+(e>>>0)+(n>>>0),Nr=(t,e,n,r)=>e+n+r+(t/2**32|0)|0,Or=(t,e,n,r)=>(t>>>0)+(e>>>0)+(n>>>0)+(r>>>0),Lr=(t,e,n,r,o)=>e+n+r+o+(t/2**32|0)|0,Ur=(t,e,n,r,o)=>(t>>>0)+(e>>>0)+(n>>>0)+(r>>>0)+(o>>>0),Rr=(t,e,n,r,o,s)=>e+n+r+o+s+(t/2**32|0)|0});var Pr,Ds,Ps,Ot,Lt,hn,Hr,Kr=R(()=>{_r();Dr();Xt();Pr=Ir(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map(t=>BigInt(t))),Ds=Pr[0],Ps=Pr[1],Ot=new Uint32Array(80),Lt=new Uint32Array(80),hn=class extends Ie{constructor(e=64){super(128,e,16,!1),this.Ah=X[0]|0,this.Al=X[1]|0,this.Bh=X[2]|0,this.Bl=X[3]|0,this.Ch=X[4]|0,this.Cl=X[5]|0,this.Dh=X[6]|0,this.Dl=X[7]|0,this.Eh=X[8]|0,this.El=X[9]|0,this.Fh=X[10]|0,this.Fl=X[11]|0,this.Gh=X[12]|0,this.Gl=X[13]|0,this.Hh=X[14]|0,this.Hl=X[15]|0}get(){let{Ah:e,Al:n,Bh:r,Bl:o,Ch:s,Cl:i,Dh:a,Dl:c,Eh:u,El:l,Fh:d,Fl:f,Gh:p,Gl:y,Hh:w,Hl:x}=this;return[e,n,r,o,s,i,a,c,u,l,d,f,p,y,w,x]}set(e,n,r,o,s,i,a,c,u,l,d,f,p,y,w,x){this.Ah=e|0,this.Al=n|0,this.Bh=r|0,this.Bl=o|0,this.Ch=s|0,this.Cl=i|0,this.Dh=a|0,this.Dl=c|0,this.Eh=u|0,this.El=l|0,this.Fh=d|0,this.Fl=f|0,this.Gh=p|0,this.Gl=y|0,this.Hh=w|0,this.Hl=x|0}process(e,n){for(let g=0;g<16;g++,n+=4)Ot[g]=e.getUint32(n),Lt[g]=e.getUint32(n+=4);for(let g=16;g<80;g++){let v=Ot[g-15]|0,_=Lt[g-15]|0,E=jt(v,_,1)^jt(v,_,8)^ln(v,_,7),B=Vt(v,_,1)^Vt(v,_,8)^dn(v,_,7),O=Ot[g-2]|0,b=Lt[g-2]|0,S=jt(O,b,19)^ie(O,b,61)^ln(O,b,6),N=Vt(O,b,19)^ce(O,b,61)^dn(O,b,6),L=Or(B,N,Lt[g-7],Lt[g-16]),A=Lr(L,E,S,Ot[g-7],Ot[g-16]);Ot[g]=A|0,Lt[g]=L|0}let{Ah:r,Al:o,Bh:s,Bl:i,Ch:a,Cl:c,Dh:u,Dl:l,Eh:d,El:f,Fh:p,Fl:y,Gh:w,Gl:x,Hh:h,Hl:m}=this;for(let g=0;g<80;g++){let v=jt(d,f,14)^jt(d,f,18)^ie(d,f,41),_=Vt(d,f,14)^Vt(d,f,18)^ce(d,f,41),E=d&p^~d&w,B=f&y^~f&x,O=Ur(m,_,B,Ps[g],Lt[g]),b=Rr(O,h,v,E,Ds[g],Ot[g]),S=O|0,N=jt(r,o,28)^ie(r,o,34)^ie(r,o,39),L=Vt(r,o,28)^ce(r,o,34)^ce(r,o,39),A=r&s^r&a^s&a,C=o&i^o&c^i&c;h=w|0,m=x|0,w=p|0,x=y|0,p=d|0,y=f|0,{h:d,l:f}=yt(u|0,l|0,b|0,S|0),u=a|0,l=c|0,a=s|0,c=i|0,s=r|0,i=o|0;let U=Tr(S,L,C);r=Nr(U,b,N,A),o=U|0}({h:r,l:o}=yt(this.Ah|0,this.Al|0,r|0,o|0)),{h:s,l:i}=yt(this.Bh|0,this.Bl|0,s|0,i|0),{h:a,l:c}=yt(this.Ch|0,this.Cl|0,a|0,c|0),{h:u,l}=yt(this.Dh|0,this.Dl|0,u|0,l|0),{h:d,l:f}=yt(this.Eh|0,this.El|0,d|0,f|0),{h:p,l:y}=yt(this.Fh|0,this.Fl|0,p|0,y|0),{h:w,l:x}=yt(this.Gh|0,this.Gl|0,w|0,x|0),{h,l:m}=yt(this.Hh|0,this.Hl|0,h|0,m|0),this.set(r,o,s,i,a,c,u,l,d,f,p,y,w,x,h,m)}roundClean(){oe(Ot,Lt)}destroy(){oe(this.buffer),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},Hr=vr(()=>new hn)});function Ne(t,e=""){if(typeof t!="boolean"){let n=e&&`"${e}"`;throw new Error(n+"expected boolean, got type="+typeof t)}return t}function ae(t,e,n=""){let r=qt(t),o=t?.length,s=e!==void 0;if(!r||s&&o!==e){let i=n&&`"${n}" `,a=s?` of length ${e}`:"",c=r?`length=${o}`:`type=${typeof t}`;throw new Error(i+"expected Uint8Array"+a+", got "+c)}return t}function Mr(t){if(typeof t!="string")throw new Error("hex string expected, got "+typeof t);return t===""?gn:BigInt("0x"+t)}function qr(t){return Mr(kt(t))}function at(t){return rt(t),Mr(kt(Uint8Array.from(t).reverse()))}function xn(t,e){return Ce(t.toString(16).padStart(e*2,"0"))}function Oe(t,e){return xn(t,e).reverse()}function Q(t,e,n){let r;if(typeof e=="string")try{r=Ce(e)}catch(s){throw new Error(t+" must be hex string or Uint8Array, cause: "+s)}else if(qt(e))r=Uint8Array.from(e);else throw new Error(t+" must be hex string or Uint8Array");let o=r.length;if(typeof n=="number"&&o!==n)throw new Error(t+" of length "+n+" expected, got "+o);return r}function kr(t,e){if(t.length!==e.length)return!1;let n=0;for(let r=0;rgn;t>>=yn,e+=1);return e}function Gt(t,e,n={}){if(!t||typeof t!="object")throw new Error("expected valid options object");function r(o,s,i){let a=t[o];if(i&&a===void 0)return;let c=typeof a;if(c!==s||a===null)throw new Error(`param "${o}" is invalid: expected ${s}, got ${c}`)}Object.entries(e).forEach(([o,s])=>r(o,s,!1)),Object.entries(n).forEach(([o,s])=>r(o,s,!0))}function bn(t){let e=new WeakMap;return(n,...r)=>{let o=e.get(n);if(o!==void 0)return o;let s=t(n,...r);return e.set(n,s),s}}var gn,yn,pn,fe,wn,Qt=R(()=>{Xt();Xt();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */gn=BigInt(0),yn=BigInt(1);pn=t=>typeof t=="bigint"&&gn<=t;fe=t=>(yn<{throw new Error("not implemented")}});function Z(t,e){let n=t%e;return n>=et?n:e+n}function it(t,e,n){let r=t;for(;e-- >et;)r*=r,r%=n;return r}function Vr(t,e){if(t===et)throw new Error("invert: expected non-zero number");if(e<=et)throw new Error("invert: expected positive modulus, got "+e);let n=Z(t,e),r=e,o=et,s=J,i=J,a=et;for(;n!==et;){let u=r/n,l=r%n,d=o-i*u,f=s-a*u;r=n,n=l,o=i,s=a,i=d,a=f}if(r!==J)throw new Error("invert: does not exist");return Z(o,e)}function En(t,e,n){if(!t.eql(t.sqr(e),n))throw new Error("Cannot find square root")}function Xr(t,e){let n=(t.ORDER+J)/Yr,r=t.pow(e,n);return En(t,r,e),r}function qs(t,e){let n=(t.ORDER-Fr)/$r,r=t.mul(e,Zt),o=t.pow(r,n),s=t.mul(e,o),i=t.mul(t.mul(s,Zt),o),a=t.mul(s,t.sub(i,t.ONE));return En(t,a,e),a}function ks(t){let e=At(t),n=zr(t),r=n(e,e.neg(e.ONE)),o=n(e,r),s=n(e,e.neg(r)),i=(t+Ks)/Wr;return(a,c)=>{let u=a.pow(c,i),l=a.mul(u,r),d=a.mul(u,o),f=a.mul(u,s),p=a.eql(a.sqr(l),c),y=a.eql(a.sqr(d),c);u=a.cmov(u,l,p),l=a.cmov(f,d,y);let w=a.eql(a.sqr(l),c),x=a.cmov(u,l,w);return En(a,x,c),x}}function zr(t){if(t1e3)throw new Error("Cannot find square root: probably non-prime P");if(n===1)return Xr;let s=o.pow(r,e),i=(e+J)/Zt;return function(c,u){if(c.is0(u))return u;if(Gr(c,u)!==1)throw new Error("Cannot find square root");let l=n,d=c.mul(c.ONE,s),f=c.pow(u,e),p=c.pow(u,i);for(;!c.eql(f,c.ONE);){if(c.is0(f))return c.ZERO;let y=1,w=c.sqr(f);for(;!c.eql(w,c.ONE);)if(y++,w=c.sqr(w),y===l)throw new Error("Cannot find square root");let x=J<(r[o]="function",r),e);return Gt(t,n),t}function Gs(t,e,n){if(net;)n&J&&(r=t.mul(r,o)),o=t.sqr(o),n>>=J;return r}function Le(t,e,n=!1){let r=new Array(e.length).fill(n?t.ZERO:void 0),o=e.reduce((i,a,c)=>t.is0(a)?i:(r[c]=i,t.mul(i,a)),t.ONE),s=t.inv(o);return e.reduceRight((i,a,c)=>t.is0(a)?i:(r[c]=t.mul(i,r[c]),t.mul(i,a)),s),r}function Gr(t,e){let n=(t.ORDER-J)/Zt,r=t.pow(e,n),o=t.eql(r,t.ONE),s=t.eql(r,t.ZERO),i=t.eql(r,t.neg(t.ONE));if(!o&&!s&&!i)throw new Error("invalid Legendre symbol result");return o?1:s?0:-1}function Jr(t,e){e!==void 0&&cn(e);let n=e!==void 0?e:t.toString(2).length,r=Math.ceil(n/8);return{nBitLength:n,nByteLength:r}}function At(t,e,n=!1,r={}){if(t<=et)throw new Error("invalid field: expected ORDER > 0, got "+t);let o,s,i=!1,a;if(typeof e=="object"&&e!=null){if(r.sqrt||n)throw new Error("cannot specify opts in two arguments");let f=e;f.BITS&&(o=f.BITS),f.sqrt&&(s=f.sqrt),typeof f.isLE=="boolean"&&(n=f.isLE),typeof f.modFromBytes=="boolean"&&(i=f.modFromBytes),a=f.allowedLengths}else typeof e=="number"&&(o=e),r.sqrt&&(s=r.sqrt);let{nBitLength:c,nByteLength:u}=Jr(t,o);if(u>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");let l,d=Object.freeze({ORDER:t,isLE:n,BITS:c,BYTES:u,MASK:fe(c),ZERO:et,ONE:J,allowedLengths:a,create:f=>Z(f,t),isValid:f=>{if(typeof f!="bigint")throw new Error("invalid field element: expected bigint, got "+typeof f);return et<=f&&ff===et,isValidNot0:f=>!d.is0(f)&&d.isValid(f),isOdd:f=>(f&J)===J,neg:f=>Z(-f,t),eql:(f,p)=>f===p,sqr:f=>Z(f*f,t),add:(f,p)=>Z(f+p,t),sub:(f,p)=>Z(f-p,t),mul:(f,p)=>Z(f*p,t),pow:(f,p)=>Gs(d,f,p),div:(f,p)=>Z(f*Vr(p,t),t),sqrN:f=>f*f,addN:(f,p)=>f+p,subN:(f,p)=>f-p,mulN:(f,p)=>f*p,inv:f=>Vr(f,t),sqrt:s||(f=>(l||(l=js(t)),l(d,f))),toBytes:f=>n?Oe(f,u):xn(f,u),fromBytes:(f,p=!0)=>{if(a){if(!a.includes(f.length)||f.length>u)throw new Error("Field.fromBytes: expected "+a+" bytes, got "+f.length);let w=new Uint8Array(u);w.set(f,n?0:w.length-f.length),f=w}if(f.length!==u)throw new Error("Field.fromBytes: expected "+u+" bytes, got "+f.length);let y=n?at(f):qr(f);if(i&&(y=Z(y,t)),!p&&!d.isValid(y))throw new Error("invalid field element: outside of range 0..ORDER");return y},invertBatch:f=>Le(d,f),cmov:(f,p,y)=>y?p:f});return Object.freeze(d)}var et,J,Zt,Zr,Yr,Fr,Ks,$r,Ms,Wr,Bt,Vs,ue=R(()=>{Qt();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */et=BigInt(0),J=BigInt(1),Zt=BigInt(2),Zr=BigInt(3),Yr=BigInt(4),Fr=BigInt(5),Ks=BigInt(7),$r=BigInt(8),Ms=BigInt(9),Wr=BigInt(16);Bt=(t,e)=>(Z(t,e)&J)===J,Vs=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"]});function to(t,e){let n=e.negate();return t?n:e}function le(t,e){let n=Le(t.Fp,e.map(r=>r.Z));return e.map((r,o)=>t.fromAffine(r.toAffine(n[o])))}function oo(t,e){if(!Number.isSafeInteger(t)||t<=0||t>e)throw new Error("invalid window size, expected [1.."+e+"], got W="+t)}function Sn(t,e){oo(t,e);let n=Math.ceil(e/t)+1,r=2**(t-1),o=2**t,s=fe(t),i=BigInt(t);return{windows:n,windowSize:r,mask:s,maxNumber:o,shiftBy:i}}function eo(t,e,n){let{windowSize:r,mask:o,maxNumber:s,shiftBy:i}=n,a=Number(t&o),c=t>>i;a>r&&(a-=s,c+=vn);let u=e*r,l=u+Math.abs(a)-1,d=a===0,f=a<0,p=e%2!==0;return{nextN:c,offset:l,isZero:d,isNeg:f,isNegF:p,offsetF:u}}function Zs(t,e){if(!Array.isArray(t))throw new Error("array expected");t.forEach((n,r)=>{if(!(n instanceof e))throw new Error("invalid point at index "+r)})}function Ys(t,e){if(!Array.isArray(t))throw new Error("array of scalars expected");t.forEach((n,r)=>{if(!e.isValid(n))throw new Error("invalid scalar at index "+r)})}function An(t){return so.get(t)||1}function no(t){if(t!==Ue)throw new Error("invalid wNAF")}function De(t,e,n,r){Zs(n,t),Ys(r,e);let o=n.length,s=r.length;if(o!==s)throw new Error("arrays of points and scalars must have equal length");let i=t.ZERO,a=jr(BigInt(o)),c=1;a>12?c=a-3:a>4?c=a-2:a>0&&(c=2);let u=fe(c),l=new Array(Number(u)+1).fill(i),d=Math.floor((e.BITS-1)/c)*c,f=i;for(let p=d;p>=0;p-=c){l.fill(i);for(let w=0;w>BigInt(p)&u);l[h]=l[h].add(n[w])}let y=i;for(let w=l.length-1,x=i;w>0;w--)x=x.add(l[w]),y=y.add(x);if(f=f.add(y),p!==0)for(let w=0;wUe))throw new Error(`CURVE.${c} must be positive bigint`)}let o=ro(e.p,n.Fp,r),s=ro(e.n,n.Fn,r),a=["Gx","Gy","a",t==="weierstrass"?"b":"d"];for(let c of a)if(!o.isValid(e[c]))throw new Error(`CURVE.${c} must be valid field element of CURVE.Fp`);return e=Object.freeze(Object.assign({},e)),{CURVE:e,Fp:o,Fn:s}}var Ue,vn,Bn,so,Re,_n=R(()=>{Qt();ue();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */Ue=BigInt(0),vn=BigInt(1);Bn=new WeakMap,so=new WeakMap;Re=class{constructor(e,n){this.BASE=e.BASE,this.ZERO=e.ZERO,this.Fn=e.Fn,this.bits=n}_unsafeLadder(e,n,r=this.ZERO){let o=e;for(;n>Ue;)n&vn&&(r=r.add(o)),o=o.double(),n>>=vn;return r}precomputeWindow(e,n){let{windows:r,windowSize:o}=Sn(n,this.bits),s=[],i=e,a=i;for(let c=0;cr.create(x),u=e.uvRatio||((x,h)=>{try{return{isValid:!0,value:r.sqrt(r.div(x,h))}}catch{return{isValid:!1,value:Ut}}});if(!$s(r,s,s.Gx,s.Gy))throw new Error("bad curve params: generator point");function l(x,h,m=!1){let g=m?F:Ut;return zt("coordinate "+x,h,g,a),h}function d(x){if(!(x instanceof y))throw new Error("ExtendedPoint expected")}let f=bn((x,h)=>{let{X:m,Y:g,Z:v}=x,_=x.is0();h==null&&(h=_?Fs:r.inv(v));let E=c(m*h),B=c(g*h),O=r.mul(v,h);if(_)return{x:Ut,y:F};if(O!==F)throw new Error("invZ was invalid");return{x:E,y:B}}),p=bn(x=>{let{a:h,d:m}=s;if(x.is0())throw new Error("bad point: ZERO");let{X:g,Y:v,Z:_,T:E}=x,B=c(g*g),O=c(v*v),b=c(_*_),S=c(b*b),N=c(B*h),L=c(b*c(N+O)),A=c(S+c(m*c(B*O)));if(L!==A)throw new Error("bad point: equation left != right (1)");let C=c(g*v),U=c(_*E);if(C!==U)throw new Error("bad point: equation left != right (2)");return!0});class y{constructor(h,m,g,v){this.X=l("x",h),this.Y=l("y",m),this.Z=l("z",g,!0),this.T=l("t",v),Object.freeze(this)}static CURVE(){return s}static fromAffine(h){if(h instanceof y)throw new Error("extended point not allowed");let{x:m,y:g}=h||{};return l("x",m),l("y",g),new y(m,g,F,c(m*g))}static fromBytes(h,m=!1){let g=r.BYTES,{a:v,d:_}=s;h=mn(ae(h,g,"point")),Ne(m,"zip215");let E=mn(h),B=h[g-1];E[g-1]=B&-129;let O=at(E),b=m?a:r.ORDER;zt("point.y",O,Ut,b);let S=c(O*O),N=c(S-F),L=c(_*S-v),{isValid:A,value:C}=u(N,L);if(!A)throw new Error("bad point: invalid y coordinate");let U=(C&F)===F,D=(B&128)!==0;if(!m&&C===Ut&&D)throw new Error("bad point: x=0 and x_0=1");return D!==U&&(C=c(-C)),y.fromAffine({x:C,y:O})}static fromHex(h,m=!1){return y.fromBytes(Q("point",h),m)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}precompute(h=8,m=!0){return w.createCache(this,h),m||this.multiply(Cn),this}assertValidity(){p(this)}equals(h){d(h);let{X:m,Y:g,Z:v}=this,{X:_,Y:E,Z:B}=h,O=c(m*B),b=c(_*v),S=c(g*B),N=c(E*v);return O===b&&S===N}is0(){return this.equals(y.ZERO)}negate(){return new y(c(-this.X),this.Y,this.Z,c(-this.T))}double(){let{a:h}=s,{X:m,Y:g,Z:v}=this,_=c(m*m),E=c(g*g),B=c(Cn*c(v*v)),O=c(h*_),b=m+g,S=c(c(b*b)-_-E),N=O+E,L=N-B,A=O-E,C=c(S*L),U=c(N*A),D=c(S*A),H=c(L*N);return new y(C,U,H,D)}add(h){d(h);let{a:m,d:g}=s,{X:v,Y:_,Z:E,T:B}=this,{X:O,Y:b,Z:S,T:N}=h,L=c(v*O),A=c(_*b),C=c(B*g*N),U=c(E*S),D=c((v+_)*(O+b)-L-A),H=U-C,P=U+C,I=c(A-m*L),q=c(D*H),j=c(P*I),k=c(D*I),z=c(H*P);return new y(q,j,z,k)}subtract(h){return this.add(h.negate())}multiply(h){if(!o.isValidNot0(h))throw new Error("invalid scalar: expected 1 <= sc < curve.n");let{p:m,f:g}=w.cached(this,h,v=>le(y,v));return le(y,[m,g])[0]}multiplyUnsafe(h,m=y.ZERO){if(!o.isValid(h))throw new Error("invalid scalar: expected 0 <= sc < curve.n");return h===Ut?y.ZERO:this.is0()||h===F?this:w.unsafe(this,h,g=>le(y,g),m)}isSmallOrder(){return this.multiplyUnsafe(i).is0()}isTorsionFree(){return w.unsafe(this,s.n).is0()}toAffine(h){return f(this,h)}clearCofactor(){return i===F?this:this.multiplyUnsafe(i)}toBytes(){let{x:h,y:m}=this.toAffine(),g=r.toBytes(m);return g[g.length-1]|=h&F?128:0,g}toHex(){return kt(this.toBytes())}toString(){return``}get ex(){return this.X}get ey(){return this.Y}get ez(){return this.Z}get et(){return this.T}static normalizeZ(h){return le(y,h)}static msm(h,m){return De(y,o,h,m)}_setWindowSize(h){this.precompute(h)}toRawBytes(){return this.toBytes()}}y.BASE=new y(s.Gx,s.Gy,F,c(s.Gx*s.Gy)),y.ZERO=new y(Ut,F,F,Ut),y.Fp=r,y.Fn=o;let w=new Re(y,o.BITS);return y.BASE.precompute(8),y}function Xs(t,e,n={}){if(typeof e!="function")throw new Error('"hash" function param is required');Gt(n,{},{adjustScalarBytes:"function",randomBytes:"function",domain:"function",prehash:"function",mapToCurve:"function"});let{prehash:r}=n,{BASE:o,Fp:s,Fn:i}=t,a=n.randomBytes||se,c=n.adjustScalarBytes||(b=>b),u=n.domain||((b,S,N)=>{if(Ne(N,"phflag"),S.length||N)throw new Error("Contexts/pre-hash are not supported");return b});function l(b){return i.create(at(b))}function d(b){let S=g.secretKey;b=Q("private key",b,S);let N=Q("hashed private key",e(b),2*S),L=c(N.slice(0,S)),A=N.slice(S,2*S),C=l(L);return{head:L,prefix:A,scalar:C}}function f(b){let{head:S,prefix:N,scalar:L}=d(b),A=o.multiply(L),C=A.toBytes();return{head:S,prefix:N,scalar:L,point:A,pointBytes:C}}function p(b){return f(b).pointBytes}function y(b=Uint8Array.of(),...S){let N=Wt(...S);return l(e(u(N,Q("context",b),!!r)))}function w(b,S,N={}){b=Q("message",b),r&&(b=r(b));let{prefix:L,scalar:A,pointBytes:C}=f(S),U=y(N.context,L,b),D=o.multiply(U).toBytes(),H=y(N.context,D,C,b),P=i.create(U+H*A);if(!i.isValid(P))throw new Error("sign failed: invalid s");let I=Wt(D,i.toBytes(P));return ae(I,g.signature,"result")}let x={zip215:!0};function h(b,S,N,L=x){let{context:A,zip215:C}=L,U=g.signature;b=Q("signature",b,U),S=Q("message",S),N=Q("publicKey",N,g.publicKey),C!==void 0&&Ne(C,"zip215"),r&&(S=r(S));let D=U/2,H=b.subarray(0,D),P=at(b.subarray(D,U)),I,q,j;try{I=t.fromBytes(N,C),q=t.fromBytes(H,C),j=o.multiplyUnsafe(P)}catch{return!1}if(!C&&I.isSmallOrder())return!1;let k=y(A,q.toBytes(),I.toBytes(),S);return q.add(I.multiplyUnsafe(k)).subtract(j).clearCofactor().is0()}let m=s.BYTES,g={secretKey:m,publicKey:m,signature:2*m,seed:m};function v(b=a(g.seed)){return ae(b,g.seed,"seed")}function _(b){let S=O.randomSecretKey(b);return{secretKey:S,publicKey:p(S)}}function E(b){return qt(b)&&b.length===i.BYTES}function B(b,S){try{return!!t.fromBytes(b,S)}catch{return!1}}let O={getExtendedPublicKey:f,randomSecretKey:v,isValidSecretKey:E,isValidPublicKey:B,toMontgomery(b){let{y:S}=t.fromBytes(b),N=g.publicKey,L=N===32;if(!L&&N!==57)throw new Error("only defined for 25519 and 448");let A=L?s.div(F+S,F-S):s.div(S-F,S+F);return s.toBytes(A)},toMontgomerySecret(b){let S=g.secretKey;ae(b,S);let N=e(b.subarray(0,S));return c(N).subarray(0,S)},randomPrivateKey:v,precompute(b=8,S=t.BASE){return S.precompute(b,!1)}};return Object.freeze({keygen:_,getPublicKey:p,sign:w,verify:h,utils:O,Point:t,lengths:g})}function zs(t){let e={a:t.a,d:t.d,p:t.Fp.ORDER,n:t.n,h:t.h,Gx:t.Gx,Gy:t.Gy},n=t.Fp,r=At(e.n,t.nBitLength,!0),o={Fp:n,Fn:r,uvRatio:t.uvRatio},s={randomBytes:t.randomBytes,adjustScalarBytes:t.adjustScalarBytes,domain:t.domain,prehash:t.prehash,mapToCurve:t.mapToCurve};return{CURVE:e,curveOpts:o,hash:t.hash,eddsaOpts:s}}function Qs(t,e){let n=e.Point;return Object.assign({},e,{ExtendedPoint:n,CURVE:t,nBitLength:n.Fn.BITS,nByteLength:n.Fn.BYTES})}function co(t){let{CURVE:e,curveOpts:n,hash:r,eddsaOpts:o}=zs(t),s=Ws(e,n),i=Xs(s,r,o);return Qs(t,i)}var Ut,F,Cn,Fs,Pe,ao=R(()=>{Qt();_n();ue();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */Ut=BigInt(0),F=BigInt(1),Cn=BigInt(2),Fs=BigInt(8);Pe=class{constructor(e){this.ep=e}static fromBytes(e){wn()}static fromHex(e){wn()}get x(){return this.toAffine().x}get y(){return this.toAffine().y}clearCofactor(){return this}assertValidity(){this.ep.assertValidity()}toAffine(e){return this.ep.toAffine(e)}toHex(){return kt(this.toBytes())}toString(){return this.toHex()}isTorsionFree(){return!0}isSmallOrder(){return!1}add(e){return this.assertSame(e),this.init(this.ep.add(e.ep))}subtract(e){return this.assertSame(e),this.init(this.ep.subtract(e.ep))}multiply(e){return this.init(this.ep.multiply(e))}multiplyUnsafe(e){return this.init(this.ep.multiplyUnsafe(e))}double(){return this.init(this.ep.double())}negate(){return this.init(this.ep.negate())}precompute(e,n){return this.init(this.ep.precompute(e,n))}toRawBytes(){return this.toBytes()}}});function Js(t){return Gt(t,{adjustScalarBytes:"function",powPminus2:"function"}),Object.freeze({...t})}function fo(t){let e=Js(t),{P:n,type:r,adjustScalarBytes:o,powPminus2:s,randomBytes:i}=e,a=r==="x25519";if(!a&&r!=="x448")throw new Error("invalid type");let c=i||se,u=a?255:448,l=a?32:56,d=BigInt(a?9:5),f=BigInt(a?121665:39081),p=a?He**BigInt(254):He**BigInt(447),y=a?BigInt(8)*He**BigInt(251)-Jt:BigInt(4)*He**BigInt(445)-Jt,w=p+y+Jt,x=A=>Z(A,n),h=m(d);function m(A){return Oe(x(A),l)}function g(A){let C=Q("u coordinate",A,l);return a&&(C[31]&=127),x(at(C))}function v(A){return at(o(Q("scalar",A,l)))}function _(A,C){let U=O(g(C),v(A));if(U===de)throw new Error("invalid private or public key received");return m(U)}function E(A){return _(A,h)}function B(A,C,U){let D=x(A*(C-U));return C=x(C-D),U=x(U+D),{x_2:C,x_3:U}}function O(A,C){zt("u",A,de,n),zt("scalar",C,p,w);let U=C,D=A,H=Jt,P=de,I=A,q=Jt,j=de;for(let z=BigInt(u-1);z>=de;z--){let ut=U>>z&Jt;j^=ut,{x_2:H,x_3:I}=B(j,H,I),{x_2:P,x_3:q}=B(j,P,q),j=ut;let lt=H+P,dt=x(lt*lt),ht=H-P,wt=x(ht*ht),bt=dt-wt,is=I+q,cs=I-q,Qn=x(cs*lt),Jn=x(is*ht),tr=Qn+Jn,er=Qn-Jn;I=x(tr*tr),q=x(D*x(er*er)),H=x(dt*wt),P=x(bt*(dt+x(f*bt)))}({x_2:H,x_3:I}=B(j,H,I)),{x_2:P,x_3:q}=B(j,P,q);let k=s(P);return x(H*k)}let b={secretKey:l,publicKey:l,seed:l},S=(A=c(l))=>(rt(A,b.seed),A);function N(A){let C=S(A);return{secretKey:C,publicKey:E(C)}}return{keygen:N,getSharedSecret:(A,C)=>_(A,C),getPublicKey:A=>E(A),scalarMult:_,scalarMultBase:E,utils:{randomSecretKey:S,randomPrivateKey:S},GuBytes:h.slice(),lengths:b}}var de,Jt,He,uo=R(()=>{Qt();ue();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */de=BigInt(0),Jt=BigInt(1),He=BigInt(2)});function yo(t){let e=BigInt(10),n=BigInt(20),r=BigInt(40),o=BigInt(80),s=te,a=t*t%s*t%s,c=it(a,lo,s)*a%s,u=it(c,_t,s)*t%s,l=it(u,ni,s)*u%s,d=it(l,e,s)*l%s,f=it(d,n,s)*d%s,p=it(f,r,s)*f%s,y=it(p,o,s)*p%s,w=it(y,o,s)*p%s,x=it(w,e,s)*l%s;return{pow_p_5_8:it(x,lo,s)*t%s,b2:a}}function go(t){return t[0]&=248,t[31]&=127,t[31]|=64,t}function On(t,e){let n=te,r=Z(e*e*e,n),o=Z(r*r*e,n),s=yo(t*o).pow_p_5_8,i=Z(t*r*s,n),a=Z(e*i*i,n),c=i,u=Z(i*In,n),l=a===t,d=a===Z(-t,n),f=a===Z(-t*In,n);return l&&(i=c),(d||f)&&(i=u),Bt(i,n)&&(i=Z(-i,n)),{isValid:l||d,value:i}}function po(t){let{d:e}=he,n=te,r=h=>vt.create(h),o=r(Tn*t*t),s=r((o+_t)*ai),i=BigInt(-1),a=r((i-e*o)*r(o+e)),{isValid:c,value:u}=On(s,a),l=r(u*t);Bt(l,n)||(l=r(-l)),c||(u=l),c||(i=o);let d=r(i*(o-_t)*fi-a),f=u*u,p=r((u+u)*a),y=r(d*ii),w=r(_t-f),x=r(_t+f);return new Yt.Point(r(p*x),r(w*y),r(y*x),r(p*w))}function li(t){rt(t,64);let e=Nn(t.subarray(0,32)),n=po(e),r=Nn(t.subarray(32,64)),o=po(r);return new Ct(n.add(o))}var ti,_t,lo,ei,ni,ri,te,he,In,vt,oi,si,Yt,Ft,Tn,ii,ci,ai,fi,ho,ui,Nn,Ct,xo=R(()=>{Kr();Xt();_n();ao();ue();uo();Qt();/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */ti=BigInt(0),_t=BigInt(1),lo=BigInt(2),ei=BigInt(3),ni=BigInt(5),ri=BigInt(8),te=BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed"),he={p:te,n:BigInt("0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed"),h:ri,a:BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec"),d:BigInt("0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3"),Gx:BigInt("0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51a"),Gy:BigInt("0x6666666666666666666666666666666666666666666666666666666666666658")};In=BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");vt=At(he.p,{isLE:!0}),oi=At(he.n,{isLE:!0}),si={...he,Fp:vt,hash:Hr,adjustScalarBytes:go,uvRatio:On},Yt=co(si),Ft=(()=>{let t=vt.ORDER;return fo({P:t,type:"x25519",powPminus2:e=>{let{pow_p_5_8:n,b2:r}=yo(e);return Z(it(n,ei,t)*r,t)},adjustScalarBytes:go})})(),Tn=In,ii=BigInt("25063068953384623474111414158702152701244531502492656460079210482610430750235"),ci=BigInt("54469307008909316920995813868745141605393597292927456921205312896311721017578"),ai=BigInt("1159843021668779879193775521855586647937357759715417654439879720876111806838"),fi=BigInt("40440834346308536858101042469323190826248399146238708352240133220865137265952"),ho=t=>On(_t,t),ui=BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),Nn=t=>Yt.Point.Fp.create(at(t)&ui);Ct=class t extends Pe{constructor(e){super(e)}static fromAffine(e){return new t(Yt.Point.fromAffine(e))}assertSame(e){if(!(e instanceof t))throw new Error("RistrettoPoint expected")}init(e){return new t(e)}static hashToCurve(e){return li(Q("ristrettoHash",e,64))}static fromBytes(e){rt(e,32);let{a:n,d:r}=he,o=te,s=v=>vt.create(v),i=Nn(e);if(!kr(vt.toBytes(i),e)||Bt(i,o))throw new Error("invalid ristretto255 encoding 1");let a=s(i*i),c=s(_t+n*a),u=s(_t-n*a),l=s(c*c),d=s(u*u),f=s(n*r*l-d),{isValid:p,value:y}=ho(s(f*d)),w=s(y*u),x=s(y*w*f),h=s((i+i)*w);Bt(h,o)&&(h=s(-h));let m=s(c*x),g=s(h*m);if(!p||Bt(g,o)||m===ti)throw new Error("invalid ristretto255 encoding 2");return new t(new Yt.Point(h,m,_t,g))}static fromHex(e){return t.fromBytes(Q("ristrettoHex",e,32))}static msm(e,n){return De(t,Yt.Point.Fn,e,n)}toBytes(){let{X:e,Y:n,Z:r,T:o}=this.ep,s=te,i=x=>vt.create(x),a=i(i(r+n)*i(r-n)),c=i(e*n),u=i(c*c),{value:l}=ho(i(a*u)),d=i(l*a),f=i(l*c),p=i(d*f*o),y;if(Bt(o*p,s)){let x=i(n*Tn),h=i(e*Tn);e=x,n=h,y=i(d*ci)}else y=f;Bt(e*p,s)&&(n=i(-n));let w=i((r-n)*y);return Bt(w,s)&&(w=i(-w)),vt.toBytes(w)}equals(e){this.assertSame(e);let{X:n,Y:r}=this.ep,{X:o,Y:s}=e.ep,i=u=>vt.create(u),a=i(n*s)===i(r*o),c=i(r*s)===i(n*o);return a||c}is0(){return this.equals(t.ZERO)}};Ct.BASE=new Ct(Yt.Point.BASE);Ct.ZERO=new Ct(Yt.Point.ZERO);Ct.Fp=vt;Ct.Fn=oi});var Ke,Ln,mo=R(()=>{Ke=t=>{let e="";for(let n of t)e+=String.fromCodePoint(n);return btoa(e)},Ln=t=>{let e=atob(t),n=new Uint8Array(e.length);for(let r=0;r{br();xo();mo();pe=32,Me=24,di=(t,e,n)=>{let r=new Uint8Array(t.length+e.length+n.length);return r.set(t,0),r.set(e,t.length),r.set(n,t.length+e.length),Ke(r)},hi=t=>{let e=Ln(t);if(e.length<=pe+Me)throw new Error("Encrypted payload is malformed.");let n=e.slice(0,pe),r=e.slice(pe,pe+Me),o=e.slice(pe+Me);return{ephemeralPublicKey:n,nonce:r,ciphertext:o}},wo=async t=>{let e=Ke(t);return{toString:()=>e,encrypt:async n=>{let r=Ft.utils.randomSecretKey(),o=Ft.getPublicKey(r),s=crypto.getRandomValues(new Uint8Array(Me)),{seal:i}=sn(Ft.getSharedSecret(r,t),s),a=i(new TextEncoder().encode(n));return di(o,s,a)}}},ye=async t=>wo(Ln(t)),pi=async t=>{let e=Ke(t);return{toString:()=>e,decrypt:async n=>{if(!n)throw new Error("Cannot decrypt an empty payload");let{ephemeralPublicKey:r,nonce:o,ciphertext:s}=hi(n),{open:i}=sn(Ft.getSharedSecret(t,r),o);return new TextDecoder().decode(i(s))}}},yi=async()=>{let t=Ft.utils.randomSecretKey(),e=Ft.getPublicKey(t);return{encryptionKey:await wo(e),decryptionKey:await pi(t)}},bo=async t=>{let{encryptionKey:e,decryptionKey:n}=await yi();if(t&&"publicKey"in t&&t.publicKey){let r=await ye(t.publicKey);return{encryptionKey:e,decryptionKey:n,relyingEncryptionKey:r}}return{encryptionKey:e,decryptionKey:n,relyingEncryptionKey:e}}});var gi,Un,So,Bo=R(()=>{gi=t=>new Uint8Array(t.match(/.{2}/g).map(e=>Number.parseInt(e,16))),Un=async t=>{let e=gi(t),n=await crypto.subtle.importKey("raw",e,{name:"AES-GCM"},!1,["encrypt","decrypt"]);return{toString:()=>t,encrypt:async r=>{let o=crypto.getRandomValues(new Uint8Array(12)),s=await crypto.subtle.encrypt({name:"AES-GCM",iv:o},n,new TextEncoder().encode(r)),i=new Uint8Array(o.length+s.byteLength);return i.set(o),i.set(new Uint8Array(s),o.length),btoa(String.fromCharCode.apply(null,Array.from(i)))},decrypt:async r=>{let o=new Uint8Array(atob(r).split("").map(u=>u.codePointAt(0))),s=o.slice(0,12),i=o.slice(12),a=await crypto.subtle.decrypt({name:"AES-GCM",iv:s},n,i);return new TextDecoder().decode(a)}}},So=async()=>{let t=new Uint8Array(16);crypto.getRandomValues(t);let e=Array.from(t).map(n=>n.toString(16).padStart(2,"0")).join("");return await Un(e)}});var Ao,vo,_o,Co=R(()=>{Ao=async t=>{let e=new TextEncoder().encode(t.toString()),n=await crypto.subtle.digest("SHA-256",e),r=new Uint8Array(n);return Array.from(r).map(s=>s.toString(16).padStart(2,"0")).join("").slice(0,16)},vo=async(t,e)=>await Ao(t)===e,_o=async(t,e)=>{let n=await Ao(e),r=t||n;return{hash:r,isHost:r===n}}});var Io,xi,To,No=R(()=>{Io="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",xi=t=>{let e=new Uint8Array(t);return crypto.getRandomValues(e),Array.from(e).map(n=>Io[n%Io.length]).join("")},To=()=>xi(16)});var Rn=R(()=>{Eo();Bo();Co();No()});var Lo=hs((ea,Dn)=>{"use strict";var mi=Object.prototype.hasOwnProperty,nt="~";function ge(){}Object.create&&(ge.prototype=Object.create(null),new ge().__proto__||(nt=!1));function wi(t,e,n){this.fn=t,this.context=e,this.once=n||!1}function Oo(t,e,n,r,o){if(typeof n!="function")throw new TypeError("The listener must be a function");var s=new wi(n,r||t,o),i=nt?nt+e:e;return t._events[i]?t._events[i].fn?t._events[i]=[t._events[i],s]:t._events[i].push(s):(t._events[i]=s,t._eventsCount++),t}function qe(t,e){--t._eventsCount===0?t._events=new ge:delete t._events[e]}function tt(){this._events=new ge,this._eventsCount=0}tt.prototype.eventNames=function(){var e=[],n,r;if(this._eventsCount===0)return e;for(r in n=this._events)mi.call(n,r)&&e.push(nt?r.slice(1):r);return Object.getOwnPropertySymbols?e.concat(Object.getOwnPropertySymbols(n)):e};tt.prototype.listeners=function(e){var n=nt?nt+e:e,r=this._events[n];if(!r)return[];if(r.fn)return[r.fn];for(var o=0,s=r.length,i=new Array(s);o{It=ys(Lo(),1)});function st(t){return Object.assign(t,{optional:()=>Ei(t),and:e=>V(t,e),or:e=>Si(t,e),select:e=>e===void 0?Uo(t):Uo(e,t)})}function Ei(t){return st({[Tt]:()=>({match:e=>{let n={},r=(o,s)=>{n[o]=s};return e===void 0?(Kt(t).forEach(o=>r(o,void 0)),{matched:!0,selections:n}):{matched:mt(t,e,r),selections:n}},getSelectionKeys:()=>Kt(t),matcherType:"optional"})})}function V(...t){return st({[Tt]:()=>({match:e=>{let n={},r=(o,s)=>{n[o]=s};return{matched:t.every(o=>mt(o,e,r)),selections:n}},getSelectionKeys:()=>xe(t,Kt),matcherType:"and"})})}function Si(...t){return st({[Tt]:()=>({match:e=>{let n={},r=(o,s)=>{n[o]=s};return xe(t,Kt).forEach(o=>r(o,void 0)),{matched:t.some(o=>mt(o,e,r)),selections:n}},getSelectionKeys:()=>xe(t,Kt),matcherType:"or"})})}function M(t){return{[Tt]:()=>({match:e=>({matched:!!t(e)})})}}function Uo(...t){let e=typeof t[0]=="string"?t[0]:void 0,n=t.length===2?t[1]:typeof t[0]=="string"?void 0:t[0];return st({[Tt]:()=>({match:r=>{let o={[e??Ve]:r};return{matched:n===void 0||mt(n,r,(s,i)=>{o[s]=i}),selections:o}},getSelectionKeys:()=>[e??Ve].concat(n===void 0?[]:Kt(n))})})}function Ro(t){return!0}function gt(t){return typeof t=="number"}function Rt(t){return typeof t=="string"}function Dt(t){return typeof t=="bigint"}function Nt(t){return new Mn(t,Kn)}function Bi(t){throw new Hn(t)}var Tt,bi,Ve,Pn,je,mt,Kt,xe,ra,oa,Pt,sa,xt,ia,Ht,ca,aa,fa,ua,la,Hn,Kn,Mn,qn=R(()=>{Tt=Symbol.for("@ts-pattern/matcher"),bi=Symbol.for("@ts-pattern/isVariadic"),Ve="@ts-pattern/anonymous-select-key",Pn=t=>!!(t&&typeof t=="object"),je=t=>t&&!!t[Tt],mt=(t,e,n)=>{if(je(t)){let r=t[Tt](),{matched:o,selections:s}=r.match(e);return o&&s&&Object.keys(s).forEach(i=>n(i,s[i])),o}if(Pn(t)){if(!Pn(e))return!1;if(Array.isArray(t)){if(!Array.isArray(e))return!1;let r=[],o=[],s=[];for(let i of t.keys()){let a=t[i];je(a)&&a[bi]?s.push(a):s.length?o.push(a):r.push(a)}if(s.length){if(s.length>1)throw new Error("Pattern error: Using `...P.array(...)` several times in a single pattern is not allowed.");if(e.lengthmt(u,i[l],n))&&o.every((u,l)=>mt(u,a[l],n))&&(s.length===0||mt(s[0],c,n))}return t.length===e.length&&t.every((i,a)=>mt(i,e[a],n))}return Reflect.ownKeys(t).every(r=>{let o=t[r];return(r in e||je(s=o)&&s[Tt]().matcherType==="optional")&&mt(o,e[r],n);var s})}return Object.is(e,t)},Kt=t=>{var e,n,r;return Pn(t)?je(t)?(e=(n=(r=t[Tt]()).getSelectionKeys)==null?void 0:n.call(r))!=null?e:[]:Array.isArray(t)?xe(t,Kt):xe(Object.values(t),Kt):[]},xe=(t,e)=>t.reduce((n,r)=>n.concat(e(r)),[]);ra=st(M(Ro)),oa=st(M(Ro)),Pt=t=>Object.assign(st(t),{startsWith:e=>{return Pt(V(t,(n=e,M(r=>Rt(r)&&r.startsWith(n)))));var n},endsWith:e=>{return Pt(V(t,(n=e,M(r=>Rt(r)&&r.endsWith(n)))));var n},minLength:e=>Pt(V(t,(n=>M(r=>Rt(r)&&r.length>=n))(e))),length:e=>Pt(V(t,(n=>M(r=>Rt(r)&&r.length===n))(e))),maxLength:e=>Pt(V(t,(n=>M(r=>Rt(r)&&r.length<=n))(e))),includes:e=>{return Pt(V(t,(n=e,M(r=>Rt(r)&&r.includes(n)))));var n},regex:e=>{return Pt(V(t,(n=e,M(r=>Rt(r)&&!!r.match(n)))));var n}}),sa=Pt(M(Rt)),xt=t=>Object.assign(st(t),{between:(e,n)=>xt(V(t,((r,o)=>M(s=>gt(s)&&r<=s&&o>=s))(e,n))),lt:e=>xt(V(t,(n=>M(r=>gt(r)&&rxt(V(t,(n=>M(r=>gt(r)&&r>n))(e))),lte:e=>xt(V(t,(n=>M(r=>gt(r)&&r<=n))(e))),gte:e=>xt(V(t,(n=>M(r=>gt(r)&&r>=n))(e))),int:()=>xt(V(t,M(e=>gt(e)&&Number.isInteger(e)))),finite:()=>xt(V(t,M(e=>gt(e)&&Number.isFinite(e)))),positive:()=>xt(V(t,M(e=>gt(e)&&e>0))),negative:()=>xt(V(t,M(e=>gt(e)&&e<0)))}),ia=xt(M(gt)),Ht=t=>Object.assign(st(t),{between:(e,n)=>Ht(V(t,((r,o)=>M(s=>Dt(s)&&r<=s&&o>=s))(e,n))),lt:e=>Ht(V(t,(n=>M(r=>Dt(r)&&rHt(V(t,(n=>M(r=>Dt(r)&&r>n))(e))),lte:e=>Ht(V(t,(n=>M(r=>Dt(r)&&r<=n))(e))),gte:e=>Ht(V(t,(n=>M(r=>Dt(r)&&r>=n))(e))),positive:()=>Ht(V(t,M(e=>Dt(e)&&e>0))),negative:()=>Ht(V(t,M(e=>Dt(e)&&e<0)))}),ca=Ht(M(Dt)),aa=st(M(function(t){return typeof t=="boolean"})),fa=st(M(function(t){return typeof t=="symbol"})),ua=st(M(function(t){return t==null})),la=st(M(function(t){return t!=null})),Hn=class extends Error{constructor(e){let n;try{n=JSON.stringify(e)}catch{n=e}super(`Pattern matching error: no pattern matches value ${n}`),this.input=void 0,this.input=e}},Kn={matched:!1,value:void 0};Mn=class t{constructor(e,n){this.input=void 0,this.state=void 0,this.input=e,this.state=n}with(...e){if(this.state.matched)return this;let n=e[e.length-1],r=[e[0]],o;e.length===3&&typeof e[1]=="function"?o=e[1]:e.length>2&&r.push(...e.slice(1,e.length-1));let s=!1,i={},a=(u,l)=>{s=!0,i[u]=l},c=!r.some(u=>mt(u,this.input,a))||o&&!o(this.input)?Kn:{matched:!0,value:n(s?Ve in i?i[Ve]:i:this.input,this.input)};return new t(this.input,c)}when(e,n){if(this.state.matched)return this;let r=!!e(this.input);return new t(this.input,r?{matched:!0,value:n(this.input,this.input)}:Kn)}otherwise(e){return this.state.matched?this.state.value:e(this.input)}exhaustive(e=Bi){return this.state.matched?this.state.value:e(this.input)}run(){return this.exhaustive()}returnType(){return this}narrow(){return this}}});var ft,kn=R(()=>{ft=(...t)=>console.log("%c[signaling]%c","color: lightblue; font-weight: bold","color: inherit; font-weight: normal",...t)});var Do=R(()=>{});var G,jn,me,Po,Vn=R(()=>{ze();Rn();ke();qn();kn();Do();G={STANDBY:"standby",CONNECTING:"connecting",READY:"ready",HANDSHAKE:"handshake",HANDSHAKE_PARTIAL:"handshake-partial",ENCRYPTED:"encrypted",ERROR:"error"},jn="x",me="h",Po=t=>async({canEncrypt:e,encrypt:n,decrypt:r,rpDiscovered:o,h:s,k:i,publicKey:a,isHost:c})=>{let u=new It.default,l=G.STANDBY,d=w=>{l=w,u.emit("state_change",w)},f=i||void 0,p=async(w,x,h)=>{let m=Nt(w).with("handshake",()=>me).with("encrypted",()=>jn).exhaustive(),g=await Nt(w).with("handshake",()=>{if(f)return f.encrypt(JSON.stringify(h))}).with("encrypted",()=>{if(e())return n(JSON.stringify(h))}).exhaustive();await t.publish(m+x+g)},y=async w=>{let x=w.slice(0,me.length),h=w.slice(me.length,me.length+1),m=w.slice(jn.length+1),g=(c?"h":"c")===h;ft("isRecipient",g),g&&await Nt({prefix:x,body:m}).with({prefix:me},async()=>{if(!f)return;let v=await f.decrypt(m),_=JSON.parse(v);await Nt({msg:_,state:l,isHost:c}).with({msg:{type:"flash"},state:G.READY,isHost:!0},async()=>{d(G.HANDSHAKE),await p("handshake","c",{type:"pubkey",payload:{publicKey:a.toString()},timestamp:Date.now()})}).with({msg:{type:"pubkey"},isHost:!1,state:G.HANDSHAKE},async({msg:{payload:E}})=>{try{let B=await ye(E.publicKey);if(!await vo(B,s)){d(G.ERROR),ft("Received host public key does not match expected hash \u2014 possible tampering");return}}catch{d(G.ERROR),ft("Failed to parse received host public key");return}return await o(E.publicKey),d(G.HANDSHAKE_PARTIAL),await p("encrypted","h",{type:"pubkey",payload:{publicKey:a.toString()},timestamp:Date.now()})}).otherwise(()=>{ft("Received invalid message H",_)})}).with({prefix:jn},async()=>{let v=await r(m),_=JSON.parse(v);await Nt({msg:_,state:l,isHost:c}).with({msg:{type:"pubkey"},isHost:!0,state:G.HANDSHAKE},async({msg:{payload:E}})=>(await o(E.publicKey),d(G.HANDSHAKE_PARTIAL),await p("encrypted","c",{type:"ack",payload:void 0,timestamp:Date.now()}))).with({msg:{type:"ack"},state:G.HANDSHAKE_PARTIAL},async()=>{if(d(G.ENCRYPTED),!c)return await p("encrypted","h",{type:"ack",payload:void 0,timestamp:Date.now()})}).with({msg:{type:"data"},state:G.ENCRYPTED},async()=>{u.emit("message",_.payload)}).otherwise(()=>{ft("Received invalid message X",_)})}).otherwise(()=>{ft("Received invalid message",w)})};return rr(u,{type:t.type,async setup(){d(G.CONNECTING),await t.setup(),await t.subscribe(y),e()?d(G.ENCRYPTED):c?d(G.READY):(d(G.READY),await p("handshake","h",{type:"flash",payload:{},timestamp:Date.now()}),d(G.HANDSHAKE))},async teardown(){ft("teardown"),await t.teardown?.()},send(w){return e()?p("encrypted",c?"c":"h",{type:"data",payload:w,timestamp:Date.now()}):Promise.reject(new Error("Cannot encrypt message before keys are exchanged"))},getState(){return{state:l}}})}});var Mo,Ko=R(()=>{Mo={name:"@openlv/core",version:"0.0.2",keywords:["open-lavatory"],homepage:"https://github.com/v3xlabs/open-lavatory",bugs:{url:"https://github.com/v3xlabs/open-lavatory/issues"},repository:{type:"git",url:"git+https://github.com/v3xlabs/open-lavatory.git",directory:"packages/core"},license:"LGPL-3.0-only",author:{name:"Open-Lavatory",url:"https://github.com/v3xlabs/open-lavatory"},sideEffects:!1,type:"module",exports:{".":{types:"./dist/index.d.ts",import:"./dist/index.js",default:"./dist/index.js"},"./encryption":{types:"./dist/encryption/index.d.ts",default:"./dist/encryption/index.js"},"./errors":{types:"./dist/errors.d.ts",default:"./dist/errors.js"},"./icons":{types:"./dist/icons/index.d.ts",default:"./dist/icons/index.js"}},main:"./dist/index.js",types:"./dist/index.d.ts",files:["dist","README.md"],dependencies:{"@noble/ciphers":"^1.3.0","@noble/curves":"^1.9.7",eventemitter3:"^5.0.1"},devDependencies:{typescript:"^5",vitest:"^4"},publishConfig:{access:"public"},scripts:{build:"tsc",test:"vitest"}}});var qo,Gn,Zn,ko,Ge,jo=R(()=>{Ko();qo=`openlv@${Mo.version}`,Gn={version:qo},Zn=class t extends Error{constructor(e,n={}){let r=n.cause instanceof t?n.cause.details:n.cause?.message?n.cause.message:n.details,o=n.cause instanceof t&&n.cause.docsPath||n.docsPath,s=Gn.getDocsUrl?.({...n,docsPath:o}),i=[e||"An error occurred.","",...n.metaMessages?[...n.metaMessages,""]:[],...s?[`Docs: ${s}`]:[],...r?[`Details: ${r}`]:[],...Gn.version?[`Version: ${Gn.version}`]:[]].join(` +`);super(i,n.cause?{cause:n.cause}:void 0),Object.defineProperty(this,"details",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"docsPath",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"metaMessages",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"shortMessage",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"version",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"name",{enumerable:!0,configurable:!0,writable:!0,value:"BaseError"}),this.details=r,this.docsPath=o,this.metaMessages=n.metaMessages,this.name=n.name??this.name,this.shortMessage=e,this.version=qo}walk(e){return ko(this,e)}},ko=(t,e)=>e?.(t)?t:t&&typeof t=="object"&&"cause"in t&&t.cause!==void 0?ko(t.cause,e):e?null:t,Ge=class extends Zn{constructor(){super(["Hello","World"].join(` +`),{name:"SignalConnectionError"})}}});function ee(t){return typeof t=="string"?Vo.encode(t):t}function Yn(t=256){let e=new Uint8Array(t),n=0;function r(s){if(n+s>e.length){let i=Math.max(e.length*2,n+s),a=new Uint8Array(i);a.set(e),e=a}}let o={writeByte(s){return r(1),e[n++]=s,o},writeUint16(s){return r(2),e[n++]=s>>8&255,e[n++]=s&255,o},writeBytes(s){return r(s.length),e.set(s,n),n+=s.length,o},writeString(s){let i=Vo.encode(s);return o.writeUint16(i.length),o.writeBytes(i),o},writeBinary(s){return o.writeUint16(s.length),o.writeBytes(s),o},writeVariableInt(s){do{let i=s%128;s=Math.floor(s/128),s>0&&(i|=128),o.writeByte(i)}while(s>0);return o},toUint8Array(){return e.subarray(0,n)},get length(){return n}};return o}function Fn(t){let e=0;return{get remaining(){return t.length-e},get position(){return e},readByte(){return t[e++]},readUint16(){let n=t[e]<<8|t[e+1];return e+=2,n},readBytes(n){let r=t.subarray(e,e+n);return e+=n,r},readString(){let n=t[e]<<8|t[e+1];e+=2;let r=t.subarray(e,e+n);return e+=n,_i.decode(r)},readVariableInt(){let n=0,r=1,o;do o=t[e++],n+=(o&127)*r,r*=128;while(o&128);return n},readRest(){let n=t.subarray(e);return e=t.length,n}}}var Vo,_i,we=R(()=>{Vo=new TextEncoder,_i=new TextDecoder});var K,Go,Zo,ne,re,Ze,Ye=R(()=>{K={CONNECT:1,CONNACK:2,PUBLISH:3,PUBACK:4,PUBREC:5,PUBREL:6,PUBCOMP:7,SUBSCRIBE:8,SUBACK:9,UNSUBSCRIBE:10,UNSUBACK:11,PINGREQ:12,PINGRESP:13,DISCONNECT:14},Go="MQTT",Zo=4,ne={CLEAN_SESSION:2,WILL_FLAG:4,WILL_QOS_0:0,WILL_QOS_1:8,WILL_QOS_2:16,WILL_RETAIN:32,PASSWORD:64,USERNAME:128},re={AT_MOST_ONCE:0,AT_LEAST_ONCE:1,EXACTLY_ONCE:2},Ze=60});function Fe(t,e,n){let r=Yn();n(r);let o=Yn();return o.writeByte(t<<4|e&15),o.writeVariableInt(r.length),o.writeBytes(r.toUint8Array()),o.toUint8Array()}function $n(t){return Fe(K.CONNECT,0,e=>{e.writeString(Go),e.writeByte(Zo);let n=0;t.clean&&(n|=ne.CLEAN_SESSION),t.will&&(n|=ne.WILL_FLAG,n|=t.will.qos<<3,t.will.retain&&(n|=ne.WILL_RETAIN)),t.password!==void 0&&(n|=ne.PASSWORD),t.username!==void 0&&(n|=ne.USERNAME),e.writeByte(n),e.writeUint16(t.keepalive??Ze),e.writeString(t.clientId),t.will&&(e.writeString(t.will.topic),e.writeBinary(t.will.payload)),t.username!==void 0&&e.writeString(t.username),t.password!==void 0&&e.writeString(t.password)})}function Ci(t){return Fe(K.SUBSCRIBE,2,e=>{e.writeUint16(t.messageId);for(let n of t.subscriptions)e.writeString(n.topic),e.writeByte(n.qos)})}function Ii(t){let e=0;return t.dup&&(e|=8),e|=(t.qos&3)<<1,t.retain&&(e|=1),Fe(K.PUBLISH,e,n=>{n.writeString(t.topic),t.qos>0&&t.messageId!==void 0&&n.writeUint16(t.messageId),n.writeBytes(ee(t.payload))})}function Ti(t){return Fe(K.PUBACK,0,e=>{e.writeUint16(t.messageId)})}function Yo(){return new Uint8Array([K.PINGREQ<<4,0])}function Fo(){return new Uint8Array([K.DISCONNECT<<4,0])}var $o,Wn=R(()=>{we();Ye();$o=t=>{switch(t.type){case K.CONNECT:return $n(t);case K.SUBSCRIBE:return Ci(t);case K.PUBLISH:return Ii(t);case K.PUBACK:return Ti(t);default:throw new Error(`Unknown packet type: ${t.type}`)}}});function Ni(t){if(t.length<2)return null;let e=Fn(t),n=e.readByte(),r=n>>4,o=n&15,s=e.readVariableInt(),a=e.position+s;if(t.length0;)n.push(t.readByte());return{type:K.SUBACK,messageId:e,granted:n}}function Ui(t,e){let n=t.readString(),r=e>>1&3,o=r>0?t.readUint16():void 0;return{type:K.PUBLISH,topic:n,payload:t.readRest(),qos:r,retain:(e&1)!==0,dup:(e&8)!==0,messageId:o}}function Ri(t){return{type:K.PUBACK,messageId:t.readUint16()}}function Wo(t){let e=[],n=0;for(;n{we();Ye()});var zo=R(()=>{});var $e=R(()=>{Ye();Xo();Wn();zo()});var We,Xn=R(()=>{We=()=>{let t=new Map;return{on(e,n){t.has(e)||t.set(e,new Set),t.get(e).add(n)},off(e,n){t.has(e)&&t.get(e).delete(n)},emit(e,...n){t.has(e)&&t.get(e).forEach(r=>r(...n))}}}});var Xe,zn=R(()=>{Xe=t=>(...e)=>{t?.debug&&console.log(...e)}});var Di,Qo,Pi,Jo=R(()=>{Wn();$e();we();Xn();zn();Di="websocket_mqtt_",Qo=t=>{let e=We(),n=Xe(t),r=1,o=!1,s=null,i=null,{clientId:a=Di+Math.random().toString(36).substring(2,15),username:c,password:u,keepalive:l=Ze,clean:d=!0,url:f,signal:p}=t,y=t.will?{topic:t.will.topic,payload:ee(t.will.payload),qos:t.will.qos??re.AT_MOST_ONCE,retain:t.will.retain??!1}:void 0,w=()=>(r=r%65535+1,r),x=()=>{if(n("starting ping interval"),l>0){let _=l*1e3/2;s=setInterval(()=>{m(Yo())},_)}},h=_=>{n("sending packet",_),m($o(_))},m=_=>{i&&i.readyState===WebSocket.OPEN?(n("sending raw data",_),i.send(_)):n("not sending raw data",_)},g=()=>{n("closing connection"),o&&i&&m(Fo()),i?.close(),o=!1,s&&(clearInterval(s),s=null)},v=()=>{i=new WebSocket(f,["mqtt"]),i.binaryType="arraybuffer",i.onopen=()=>{let B=$n({type:K.CONNECT,clientId:a,username:c,password:u,keepalive:l,clean:d,will:y});m(B)};let _=new Uint8Array(0),E=B=>{switch(n("handlePacket2",B),B.type){case K.CONNACK:if(B.returnCode===0)o=!0,x(),e.emit("connect",B),n("connected");else{let O=Pi(B.returnCode);console.error(O),e.emit("error",new Error(O)),g()}break;case K.PUBLISH:n("received a publish packet"),B.qos===1&&B.messageId!==void 0&&h({type:K.PUBACK,messageId:B.messageId}),e.emit("message",B.topic,B.payload,B);break;case K.PINGRESP:n("PINGRESP");break;default:n("received a default packet"),e.emit("packet",B);break}};i.onmessage=B=>{n("onmessage");let O=new Uint8Array(B.data),b=new Uint8Array(_.length+O.length);b.set(_),b.set(O,_.length),_=b;let{packets:S,remaining:N}=Wo(_);_=N,n("packets to process",S.length);for(let L of S)n("packet",L),E(L)},i.onerror=B=>{e.emit("error",B)},i.onclose=()=>{n("ws:onclose"),g()}};return p?.addEventListener("abort",()=>{n("abort signal received"),g()}),{close:g,open:v,send:h,nextMessageId:w,isConnected:()=>o,...e}},Pi=t=>({1:"Connection refused: unacceptable protocol version",2:"Connection refused: identifier rejected",3:"Connection refused: server unavailable",4:"Connection refused: bad username or password",5:"Connection refused: not authorized"})[t]||`Connection refused: ${t}`});var ts,es=R(()=>{Jo();$e();we();Xn();zn();ts=t=>{let e={sub:new Map,pub:new Map},n=We(),r=Xe(t),o=Qo(t);return o.on("connect",c=>{n.emit("connect",c)}),o.on("message",(c,u,l)=>{n.emit("message",c,u,l)}),o.on("packet",c=>{switch(c.type){case K.SUBACK:{r("SUBACK",c);let u=e.sub.get(c.messageId);u&&(e.sub.delete(c.messageId),c.granted.some(d=>d===128)?u.reject(new Error("Subscription failed")):u.resolve(c.granted));break}case K.PUBACK:{r("PUBACK",c);let u=e.pub.get(c.messageId);u&&(e.pub.delete(c.messageId),u.resolve());break}}}),o.on("error",c=>{console.error("error",c),n.emit("error",c)}),o.on("close",()=>{for(let c of e.sub.values())c.reject(new Error("Connection closed"));e.sub.clear();for(let c of e.pub.values())c.reject(new Error("Connection closed"));e.pub.clear(),n.emit("close")}),{...n,publish:(c,u,l)=>{let d=l?.qos??re.AT_MOST_ONCE,f=l?.retain??!1,p=d>0?o.nextMessageId():void 0;return o.send({type:K.PUBLISH,topic:c,payload:ee(u),qos:d,retain:f,dup:!1,messageId:p}),d===0?Promise.resolve():new Promise((y,w)=>{p&&e.pub.set(p,{resolve:y,reject:w})})},subscribe:(c,u=re.AT_MOST_ONCE)=>new Promise((l,d)=>{let f=o.nextMessageId();e.sub.set(f,{resolve:l,reject:d}),o.send({type:K.SUBSCRIBE,messageId:f,subscriptions:[{topic:c,qos:u}]})}),connection:o,isConnected:()=>o.isConnected(),connect:()=>new Promise((c,u)=>{o.open(),o.on("connect",()=>c()),o.on("error",l=>u(l))}),close:()=>{r("closing from up above"),o.close()}}}});var ns=R(()=>{es();$e()});var rs,os=R(()=>{jo();ns();Vn();kn();rs=({url:t="wss://test.mosquitto.org:8081/mqtt",topic:e})=>{let n;return Po({type:"mqtt",setup(){return new Promise((r,o)=>{n=ts({url:t}),n.on("connect",()=>{r()}),n.on("error",s=>{console.error("MQTT: Error connecting to URL",s),o(s)}),n.on("close",()=>{o(new Error("MQTT: Closed connection to URL"))}),n.connect()})},teardown(){n?.close(),n=void 0},async publish(r){if(!n)throw new Error("MQTT: No connection to publish to");n?.publish(e,r,{retain:!1})},async subscribe(r){if(!n)throw new Ge;ft("MQTT: Subscribing to topic",e),n.on("message",(o,s)=>{if(ft("MQTT: Received message on topic",e),o!==e)return;let i=new TextDecoder().decode(s);r(i)}),await n?.subscribe(e)}})};Object.defineProperty(rs,"__name",{value:"mqtt"})});Rn();Vn();ke();var $=(...t)=>console.log(...t);var $t={STANDBY:"standby",CONNECTING:"connecting",READY:"ready",CONNECTED:"connected",ERROR:"error"},Ho=t=>({encrypt:e,decrypt:n,subsend:r,isHost:o,onmessage:s})=>{let i=new It.default,a=new It.default,c=$t.STANDBY,u=h=>{c=h,i.emit("state_change",h)};a.on("offer",h=>{$("onOffer",h),r({type:"offer",payload:h})}),a.on("answer",h=>{$("onAnswer",h),r({type:"answer",payload:h})}),a.on("candidate",h=>{$("onCandidate",h),r({type:"candidate",payload:h})}),a.on("connected",()=>{$("onConnected"),u($t.CONNECTED)}),a.on("message",async h=>{$("onMessage",h);let m=await n(h);s(JSON.parse(m))});let{setup:l,teardown:d,type:f,send:p,handle:y}=t({emitter:a,isHost:o});return{type:f,async setup(){u($t.CONNECTING),await l(),u($t.READY)},teardown:d,handle:y,send:async h=>{if(c!==$t.CONNECTED)throw new Error("Transport not connected");let m=await e(JSON.stringify(h));await p(m)},waitFor:async h=>{if(c!==h)return new Promise(m=>{let g=v=>{v===h&&(i.off("state_change",g),m())};i.on("state_change",g)})},emitter:i}};ke();var ot=(...t)=>console.log("[session]",...t);var ct={CREATED:"created",SIGNALING:"signaling",READY:"ready",LINKING:"linking",CONNECTED:"connected",DISCONNECTED:"disconnected"},Ai=async(t,e,n,r)=>{let o=new It.default,s=new It.default,i="sessionId"in t?t.sessionId:To(),{encryptionKey:a,decryptionKey:{decrypt:c}}=await bo(t),u,l="k"in t?await Un(t.k):await So(),{hash:d,isHost:f}=await _o("h"in t?t.h:void 0,a),p=ct.CREATED,y=t.p,w=t.s,x=E=>{ot("updateStatus",E),p=E,o.emit("state_change",{status:p,signaling:m.getState()})},m=await(await e({topic:i,url:w}))({h:d,canEncrypt(){return u!==void 0},async encrypt(E){if(!u)throw new Error("Relying party public key not found");return ot(`encrypting to ${u.toString()}`,E),await u.encrypt(E)},decrypt:c,publicKey:a,k:l,async rpDiscovered(E){ot(`rpKey discovered by ${f?"host":"client"}`,E),u=await ye(E)},isHost:f}),g=n[0]({encrypt(E){if(!u)throw new Error("Relying party public key not found");return u?.encrypt(E)},decrypt:c,isHost:f,onmessage:async E=>{if(ot("Session: received message from transport",E),E.type==="request"){let B=E.messageId;await g.send({type:"ack",messageId:B}),o.emit("request",E.payload);let O=await r(E.payload),b={type:"response",messageId:B,payload:O};await g.send(b)}(E.type==="response"||E.type==="ack")&&s.emit("message",E)},async subsend(E){ot("Session: sending trans msg to signal",E);let B={type:"request",messageId:crypto.randomUUID(),payload:E};await m.send(B)}});g.emitter.on("state_change",E=>{ot("transport state change",E),E===$t.CONNECTED&&x(ct.CONNECTED)});let v=async()=>{await g.setup()},_=async E=>{ot("Session: received message from signaling",E);let B=E;B.type==="response"&&(ot("Session: received data message",B.payload),s.emit("message",B)),B.type==="request"&&(ot("Session: received request message",B.payload),await g.handle(B.payload))};return{connect:async()=>{x(ct.SIGNALING),ot("connecting to session, isHost:",f),m.on("message",_),m.on("state_change",E=>{ot("signal state change",E),E===G.READY&&x(ct.READY),[G.HANDSHAKE,G.HANDSHAKE_PARTIAL].includes(E)&&x(ct.LINKING),E===G.ENCRYPTED&&v(),E===G.ERROR&&(ot("signaling error \u2014 marking session disconnected"),x(ct.DISCONNECTED))}),await m.setup()},async close(){ot("session teardown"),m.off("message",_),await Promise.all([g.teardown(),m.teardown()]),x(ct.DISCONNECTED)},getState(){return{status:p,signaling:m.getState()}},getHandshakeParameters(){return{version:1,sessionId:i,h:d,k:l.toString(),p:y,s:w}},waitForLink:async()=>{if(p!==ct.CONNECTED){if(p===ct.DISCONNECTED)throw new Error("Session failed to connect");return new Promise((E,B)=>{let O=b=>{b?.status===ct.CONNECTED?(o.off("state_change",O),E()):b?.status===ct.DISCONNECTED&&(o.off("state_change",O),B(new Error("Session failed to connect")))};o.on("state_change",O)})}},async send(E,B=1e4,O=60*6e4){if(m.getState().state!==G.ENCRYPTED)throw new Error("Session not ready");let b=crypto.randomUUID(),S={type:"request",messageId:b,payload:E};return await g.send(S),new Promise((N,L)=>{let A=!1,C,U,D=()=>{clearTimeout(C),clearTimeout(U),s.off("message",H)},H=P=>{if(P.messageId===b){if(P.type==="ack"&&!A){A=!0,clearTimeout(C),U=setTimeout(()=>{D(),L(new Error("Request timed out: no response after acknowledgement"))},O);return}P.type==="response"&&(D(),N(P.payload))}};s.on("message",H),C=setTimeout(()=>{A||(D(),L(new Error("Request timed out: remote peer did not acknowledge")))},B)})},_internal:{signal:m,transport:g},emitter:o}};ze();os();qn();var ss={iceServers:[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"},{urls:"stun:stun.services.mozilla.com:3478"},{urls:["turn:openrelay.metered.ca:80","turn:openrelay.metered.ca:443"],username:"openrelayproject",credential:"openrelayproject"}]},Hi=(t=ss)=>{let{iceServers:e=ss.iceServers}=t;console.log("WEBRTC ICE CONFIG",t);let n=Math.random().toString(36).slice(2,4)+"#";return Ho(({emitter:r,isHost:o})=>{let s={iceServers:e},i,a,c=()=>{$(n,"onConnectionStateChange",i?.connectionState);let h=i?.connectionState;Nt(h).with("disconnected",()=>{}).with("failed",()=>{}).otherwise(()=>{$(n,"onConnectionStateChangeUnknown",h)})},u=h=>{a?.readyState!=="open"&&($(n,"onIceCandidate",h.candidate),r.emit("candidate",JSON.stringify(h.candidate?.toJSON())))},l=h=>{a=h.channel,y(a),$(n,"onDataChannel",a)},d=()=>{$(n,"onDataChannelOpen"),r.emit("connected")},f=h=>{$(n,"onDataChannelMessage",h.data),r.emit("message",h.data)},p=async()=>{$(n,"onNegotiationNeeded"),o&&i&&(await i.setLocalDescription(),i.localDescription&&r.emit("offer",JSON.stringify(i.localDescription)))},y=h=>{h.addEventListener("open",d),h.addEventListener("message",f)};return{type:"webrtc",async setup(){$("webrtc setupz",s),i=new RTCPeerConnection(s),i.onconnectionstatechange=c,i.onicecandidate=u,i.ondatachannel=l,i.onnegotiationneeded=p,o&&(a=i.createDataChannel("openlv-data"),y(a))},teardown(){$("webrtc teardown"),a&&(a.close(),a=void 0),i&&(i.close(),i=void 0)},handle:async h=>{if($(n,"webrtc handle",h),!i)throw new Error("Connection not found");return Nt(h).with({type:"offer"},async({payload:m})=>{let g=JSON.parse(m);await i.setRemoteDescription(new RTCSessionDescription(g));let v=await i.createAnswer();await i.setLocalDescription(v),r.emit("answer",JSON.stringify(v))}).with({type:"answer"},async({payload:m})=>{if(!i)throw new Error("Connection not found");let g=JSON.parse(m);await i.setRemoteDescription(new RTCSessionDescription(g))}).with({type:"candidate"},async({payload:m})=>{if(!i)throw new Error("Connection not found");if(!m)return;let g=JSON.parse(m);await i.addIceCandidate(new RTCIceCandidate(g))}).otherwise(()=>{$(n,"received unknown transport message type",h)})},send:async h=>{if(!a)throw new Error("Channel not found");a.send(h)}}})};export{cr as OPENLV_PROTOCOL_VERSION,ct as SESSION_STATE,Ai as createSession,xs as decodeConnectionURL,gs as encodeConnectionURL,rs as mqtt,Hi as webrtc}; From 9629517f752722ed0b221aec605278eb49fdff6a Mon Sep 17 00:00:00 2001 From: Florian Glatz Date: Wed, 8 Jul 2026 23:24:49 +0200 Subject: [PATCH 02/39] feat(wallet): phone account model and Connect Phone flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add remote (phone) accounts to the wallet list and the sidebar flow to connect one: show an openlv QR, receive the phone's accounts over the encrypted P2P session (eth_requestAccounts), pick and name one, and switch to it. Works with the vault locked — the key stays on the phone. - identity-manager: addDeviceWallet generalizes the Ledger add path (shared validation/dedup/auto-naming via DEVICE_LABELS); addRemoteWallet + wallet:add-remote-wallet IPC; device-neutral key-guard messages - broker connectPhone(): local discovery job reusing the session/ teardown/cancel plumbing via a per-job respond seam - Connect Phone subscreen with QR/status/retry, account picker, and success step; wallet selector gains the entry point and per-type badges (Ledger/Phone) - shared device-account picker + inline-error helpers + activateAddedWallet extracted; connect-ledger migrated to them Co-Authored-By: Claude Fable 5 --- src/main/identity-manager.js | 81 ++++-- src/main/identity-manager.test.js | 45 ++- src/main/preload.js | 2 + src/renderer/index.html | 84 ++++++ src/renderer/lib/wallet-ui.js | 5 +- src/renderer/lib/wallet/connect-ledger.js | 72 +---- src/renderer/lib/wallet/connect-phone.js | 263 ++++++++++++++++++ .../lib/wallet/device-account-list.js | 56 ++++ src/renderer/lib/wallet/remote-session.js | 49 +++- .../lib/wallet/remote-session.test.js | 51 ++++ src/renderer/lib/wallet/wallet-selector.js | 38 ++- src/renderer/lib/wallet/wallet-utils.js | 15 + 12 files changed, 673 insertions(+), 88 deletions(-) create mode 100644 src/renderer/lib/wallet/connect-phone.js create mode 100644 src/renderer/lib/wallet/device-account-list.js diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index 31a93302..5ace287a 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -286,7 +286,7 @@ async function getUserWalletKey(walletIndex) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } if (record.type !== WALLET_TYPES.MNEMONIC) { - throw new Error('Hardware wallet accounts have no derivable private key'); + throw new Error('This account has no derivable private key — the key never leaves its device'); } const identity = await loadIdentityModule(); @@ -796,6 +796,12 @@ const WALLET_TYPES = { REMOTE: 'remote', // phone / other device signing over openlv }; +/** User-facing labels for device account types (auto-names, error text). */ +const DEVICE_LABELS = { + [WALLET_TYPES.LEDGER]: 'Ledger', + [WALLET_TYPES.REMOTE]: 'Phone', +}; + /** * The wallet list stored in vault-meta, with the implicit pre-multi-wallet * default (just the main wallet) when `derivedWallets` was never written. @@ -885,9 +891,9 @@ async function getDerivedWallets() { const type = wallet.type || WALLET_TYPES.MNEMONIC; let address = null; - if (type === WALLET_TYPES.LEDGER) { - // Hardware accounts: the address was read from the device when the - // account was added; there is nothing to derive locally. + if (type !== WALLET_TYPES.MNEMONIC) { + // Device accounts (Ledger, phone): the address was read from the + // device when the account was added; nothing to derive locally. address = wallet.address || null; } else if (mnemonic) { // Derive address from mnemonic @@ -915,25 +921,23 @@ async function getDerivedWallets() { } /** - * Add a Ledger hardware-wallet account to the wallet list. + * Add a device account (Ledger, phone) to the wallet list. * - * The address comes from the device during account discovery and is + * The address comes from the device when the account is added and is * persisted — it can never be re-derived locally. Does not require the * vault to be unlocked (no mnemonic involved), only that a vault exists * so there is a wallet list to add to. * - * @param {string} name - Display name ('' → auto "Ledger N") - * @param {string} address - Checksummed address read from the device - * @param {string} path - Derivation path in device format (e.g. "44'/60'/0'/0/0") - * @returns {Promise<{index: number, name: string, address: string, type: string, path: string}>} + * @param {string} type - WALLET_TYPES.LEDGER or WALLET_TYPES.REMOTE + * @param {string} name - Display name ('' → auto "