feat(signer): QR pairing — client-initiated nostrconnect:// flow for Amber
Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no link — it scans one — so the signer screen now mints a pairing token: - start_pairing(): ephemeral key + secret, nostrconnect:// token via NostrConnectUri::client_with_secret, status().pairing_uri for the GUI - run_pairing_task(): listens for the signer's connect request, echoes the secret (anti-spoofing), persists the connection row + secret, then adopts identity via get_public_key and hands to the demux loop - pairing subscription is closed at handoff so the demux loop owns the conversation (relay could otherwise deliver signer replies under the stale pairing sub id where nobody routes them) - IPC: nip46_pair_start; status carries pairing_uri - SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token, copy-link fallback, cancel; paste-link flow unchanged - e2e: fake QR scanner consumes the real pairing token end-to-end (scan -> secret echo -> identity -> sign -> vault persistence)
This commit is contained in:
parent
764406e5a9
commit
38499d4506
12 changed files with 1041 additions and 49 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1283,6 +1283,7 @@ version = "0.45.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"base64",
|
||||
"bech32",
|
||||
"bip39",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nostr = { version = "0.45", features = ["nip44", "nip98"] }
|
||||
nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] }
|
||||
nostr-sdk = "0.45"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
|
|||
218
frontend/package-lock.json
generated
218
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.25.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
|
|
@ -1537,6 +1539,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
|
|
@ -2028,7 +2040,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -2038,7 +2049,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
|
|
@ -2516,6 +2526,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
@ -2608,7 +2627,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -2621,7 +2639,6 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
|
|
@ -2769,6 +2786,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -2898,6 +2924,12 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dir-compare": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||
|
|
@ -3187,7 +3219,6 @@
|
|||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
|
|
@ -3761,7 +3792,6 @@
|
|||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
|
|
@ -4218,7 +4248,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -5250,6 +5279,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -5280,7 +5318,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -5394,6 +5431,15 @@
|
|||
"node": ">=10.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
|
|
@ -5601,6 +5647,141 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
|
|
@ -5693,7 +5874,6 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -5709,6 +5889,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
|
|
@ -5936,6 +6122,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -6063,7 +6255,6 @@
|
|||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
|
|
@ -6078,7 +6269,6 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
|
@ -6764,6 +6954,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.25.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export const api = {
|
|||
// NIP-46 client signer
|
||||
nip46Connect: (uri: string, label: string) =>
|
||||
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
||||
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
|
||||
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
||||
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
||||
nip46Approve: (id: string, approved: boolean) =>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export interface Nip46SignerStatus {
|
|||
connected_relays: string[];
|
||||
error?: string;
|
||||
pending_approvals: PendingApproval[];
|
||||
/** nostrconnect:// pairing token while a QR pairing is in flight. */
|
||||
pairing_uri?: string;
|
||||
}
|
||||
|
||||
/** Union of all signer statuses. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Alert } from '../components/Alert';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { Button } from '../components/Button';
|
||||
|
|
@ -14,6 +15,7 @@ export function SignerModeScreen() {
|
|||
embeddedSignerStatus,
|
||||
nip46Status,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Approve,
|
||||
embeddedSignerApprove,
|
||||
|
|
@ -30,6 +32,8 @@ export function SignerModeScreen() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pairingQr, setPairingQr] = useState<string | null>(null);
|
||||
const [pairError, setPairError] = useState<string | null>(null);
|
||||
|
||||
// Single source of truth: backend state (defaults to most secure)
|
||||
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
|
||||
|
|
@ -148,6 +152,58 @@ export function SignerModeScreen() {
|
|||
}
|
||||
}, [uri, label, nip46Connect]);
|
||||
|
||||
// Start a client-initiated pairing: the backend mints a nostrconnect://
|
||||
// token and waits for the signer (Amber) to scan it. The token arrives via
|
||||
// status().pairing_uri; we render it as a QR.
|
||||
const handlePairStart = useCallback(async () => {
|
||||
setPairError(null);
|
||||
setConnecting(true);
|
||||
try {
|
||||
const status = await nip46PairStart(label.trim() || 'Remote Signer');
|
||||
setNip46StatusState(status);
|
||||
} catch (err) {
|
||||
setPairError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [label, nip46PairStart]);
|
||||
|
||||
const pairingUri = nip46StatusState?.pairing_uri ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pairingUri) {
|
||||
setPairingQr(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
QRCode.toDataURL(pairingUri, {
|
||||
width: 480,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) setPairingQr(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPairError('Could not render the pairing QR code.');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pairingUri]);
|
||||
|
||||
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
|
||||
// disconnect; nothing was persisted yet so it is safe at any point.
|
||||
const handlePairCancel = useCallback(async () => {
|
||||
setPairError(null);
|
||||
try {
|
||||
const status = await nip46Disconnect();
|
||||
setNip46StatusState(status);
|
||||
} catch (err) {
|
||||
setPairError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [nip46Disconnect]);
|
||||
|
||||
const handleNip46Disconnect = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -505,6 +561,44 @@ export function SignerModeScreen() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pairingUri ? (
|
||||
<div className="signer-pairing">
|
||||
<p>
|
||||
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) to connect.
|
||||
</p>
|
||||
{pairingQr && (
|
||||
<img
|
||||
src={pairingQr}
|
||||
alt="Pairing QR code"
|
||||
style={{
|
||||
width: 260,
|
||||
height: 260,
|
||||
imageRendering: 'pixelated',
|
||||
borderRadius: 8,
|
||||
display: 'block',
|
||||
margin: '12px auto',
|
||||
background: '#fff',
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="hint" style={{ textAlign: 'center' }}>
|
||||
Waiting for the signer to scan… the connection appears automatically once
|
||||
approved. The code expires after a few minutes.
|
||||
</p>
|
||||
{pairError && <ErrorText>{pairError}</ErrorText>}
|
||||
<div className="settings-inline" style={{ justifyContent: 'center' }}>
|
||||
<Button variant="ghost" onClick={() => void handlePairCancel()}>
|
||||
Cancel pairing
|
||||
</Button>
|
||||
</div>
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary className="hint">Or copy the pairing link</summary>
|
||||
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
|
||||
{pairingUri}
|
||||
</code>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="field">
|
||||
|
|
@ -522,7 +616,7 @@ export function SignerModeScreen() {
|
|||
/>
|
||||
<p className="hint">
|
||||
{mode === 'nip46_client'
|
||||
? 'In Amber, open Connect and copy the bunker:// link. In other Nostr Connect apps, copy the nostrconnect:// link. Then paste it here.'
|
||||
? 'Easiest: press “Show QR” below and scan it with Amber. Or paste a bunker:// link from a self-hosted bunker / nostrconnect:// link from another app.'
|
||||
: 'Share this with client apps that want to connect to this bunker.'}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -535,10 +629,20 @@ export function SignerModeScreen() {
|
|||
/>
|
||||
</div>
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
{pairError && <ErrorText>{pairError}</ErrorText>}
|
||||
<div className="settings-inline">
|
||||
{mode === 'nip46_client' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={connecting}
|
||||
onClick={() => void handlePairStart()}
|
||||
>
|
||||
<Icon name="key" size={16} /> Show QR
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={mode === 'nip46_client' ? 'ghost' : 'primary'}
|
||||
loading={connecting}
|
||||
disabled={!uri.trim()}
|
||||
onClick={() => void handleNip46Connect()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ interface AppContextValue {
|
|||
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
|
||||
// NIP-46 client signer
|
||||
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
||||
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
|
||||
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
||||
nip46Status: () => Promise<Nip46SignerStatus>;
|
||||
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
|
||||
|
|
@ -264,6 +265,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
|
||||
const nip46Status = useCallback(() => api.nip46Status(), []);
|
||||
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
|
||||
const nip46Approve = useCallback(
|
||||
(id: string, approved: boolean) => api.nip46Approve(id, approved),
|
||||
[],
|
||||
|
|
@ -350,6 +352,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
|
|
@ -407,6 +410,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
|
|
|
|||
22
src/ipc.rs
22
src/ipc.rs
|
|
@ -165,6 +165,12 @@ pub enum Request {
|
|||
uri: String,
|
||||
label: String,
|
||||
},
|
||||
/// Start a client-initiated pairing: the reply carries `pairing_uri`
|
||||
/// (a nostrconnect:// token) for the GUI to render as a QR the signer
|
||||
/// app scans. Status polls report when the scan lands.
|
||||
Nip46PairStart {
|
||||
label: String,
|
||||
},
|
||||
/// Disconnect from the NIP-46 signer.
|
||||
Nip46Disconnect,
|
||||
/// Get NIP-46 connection status.
|
||||
|
|
@ -412,6 +418,22 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
|||
let status = signer.connect(&uri, label).await?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
Request::Nip46PairStart { label } => {
|
||||
// Same lock discipline as Nip46Connect: pairing persists to the
|
||||
// vault from its background task, so the guard must not be held
|
||||
// across the await.
|
||||
let signer = {
|
||||
let guard = app.lock().await;
|
||||
guard.nip46_signer.clone()
|
||||
};
|
||||
let Some(signer) = signer else {
|
||||
return Err(AppError::config(
|
||||
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
|
||||
));
|
||||
};
|
||||
let status = signer.start_pairing(label).await?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
Request::Nip46Disconnect => {
|
||||
let guard = app.lock().await;
|
||||
if let Some(signer) = &guard.nip46_signer {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use base64::Engine;
|
|||
use getrandom::getrandom;
|
||||
use nostr::nips::nip44::v2;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr::nips::nip46::NostrConnectUri;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
|
@ -34,9 +35,14 @@ const MAX_PENDING_APPROVALS: usize = 20;
|
|||
/// How long an outbound NIP-46 request (e.g. our own `sign_event`) may wait
|
||||
/// for the remote signer's response before it is abandoned.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// The connect handshake can involve a human approving the app on the
|
||||
/// signer's screen, so it gets a far longer leash than ordinary RPCs.
|
||||
/// How long the connect handshake can involve a human approving the app on
|
||||
/// the signer's screen, so it gets a far longer leash than ordinary RPCs.
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// How long a pairing QR (client-initiated `nostrconnect://`) stays live
|
||||
/// while a human opens their signer and scans it.
|
||||
const PAIRING_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// App name advertised to signers during client-initiated pairing.
|
||||
const APP_NAME: &str = "Keynectr";
|
||||
|
||||
/// Internal state for a pending approval.
|
||||
struct PendingApprovalInner {
|
||||
|
|
@ -45,6 +51,16 @@ struct PendingApprovalInner {
|
|||
sender: oneshot::Sender<ApprovalResult>,
|
||||
}
|
||||
|
||||
/// A client-initiated pairing session: we minted an ephemeral key + secret,
|
||||
/// published a `nostrconnect://` token for the signer to scan, and are
|
||||
/// listening on the relays for the signer's inbound `connect` request.
|
||||
struct PairingSession {
|
||||
/// The `nostrconnect://` URI to render as a QR / copyable link.
|
||||
uri: String,
|
||||
/// The task waiting for the inbound connect request.
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// A response awaited from the *remote signer* for a request we sent
|
||||
/// (the client half of the NIP-46 flow, e.g. our `sign_event` request).
|
||||
struct PendingRemoteRequest {
|
||||
|
|
@ -77,6 +93,8 @@ struct Nip46Inner {
|
|||
remote_pending: HashMap<String, PendingRemoteRequest>,
|
||||
keys: Option<Keys>,
|
||||
active_npub: Option<String>,
|
||||
/// An in-flight client-initiated pairing (QR) session, if any.
|
||||
pairing: Option<PairingSession>,
|
||||
/// The remote signer's REAL identity key, learned from the
|
||||
/// `get_public_key` RPC after the connect handshake completes. The
|
||||
/// pubkey in the connect URI may be a per-connection communication key
|
||||
|
|
@ -108,6 +126,7 @@ impl Nip46ClientSigner {
|
|||
remote_pending: HashMap::new(),
|
||||
keys: None,
|
||||
active_npub: None,
|
||||
pairing: None,
|
||||
identity: None,
|
||||
})),
|
||||
app,
|
||||
|
|
@ -355,6 +374,9 @@ impl Nip46ClientSigner {
|
|||
if let Some(task) = inner.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(pairing) = inner.pairing.take() {
|
||||
pairing.task.abort();
|
||||
}
|
||||
if let Some(client) = inner.client.take() {
|
||||
let _ = client.disconnect().await;
|
||||
}
|
||||
|
|
@ -391,6 +413,307 @@ impl Nip46ClientSigner {
|
|||
self.disconnect().await
|
||||
}
|
||||
|
||||
/// Begin a client-initiated pairing (NIP-46 §Direct connection initiated
|
||||
/// by the client): we mint an ephemeral key + secret, publish a
|
||||
/// `nostrconnect://` token (returned in `status().pairing_uri` for the
|
||||
/// GUI to render as a QR), and wait on the configured relays for the
|
||||
/// signer app (Amber and friends) to scan it and send us a `connect`
|
||||
/// request. When it arrives we echo the secret back (anti-spoofing),
|
||||
/// then resolve the signer's identity exactly like the bunker:// flow.
|
||||
pub async fn start_pairing(&self, label: String) -> Result<Nip46Status, AppError> {
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
if inner.task.is_some() || inner.pairing.is_some() {
|
||||
return Err(AppError::config(
|
||||
"Already connected or pairing with a signer. Disconnect first.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Pair over the user's enabled relays; fall back to the app defaults
|
||||
// when none are configured.
|
||||
let relays: Vec<RelayUrl> = {
|
||||
let app = self.app.lock().await;
|
||||
let mut urls: Vec<String> = app
|
||||
.settings
|
||||
.relays
|
||||
.iter()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.url.clone())
|
||||
.collect();
|
||||
if urls.is_empty() {
|
||||
urls = crate::relays::default_relays()
|
||||
.into_iter()
|
||||
.map(|r| r.url)
|
||||
.collect();
|
||||
}
|
||||
urls
|
||||
}
|
||||
.iter()
|
||||
.filter_map(|u| RelayUrl::parse(u).ok())
|
||||
.collect();
|
||||
if relays.is_empty() {
|
||||
return Err(AppError::config(
|
||||
"No usable relays are configured, so there is nowhere to pair over.",
|
||||
));
|
||||
}
|
||||
|
||||
// Ephemeral session keys: the URI authority is this throwaway key,
|
||||
// never a profile key. The secret proves WE are the app the user
|
||||
// scanned — the signer must echo it back.
|
||||
let keys = Keys::generate();
|
||||
let secret = crate::crypto::random_bytes::<16>()?
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
let uri = NostrConnectUri::client_with_secret(
|
||||
keys.public_key(),
|
||||
relays.clone(),
|
||||
APP_NAME,
|
||||
secret.clone(),
|
||||
)
|
||||
.to_string();
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
// Re-check under the lock: a concurrent connect() must lose.
|
||||
if inner.task.is_some() || inner.pairing.is_some() {
|
||||
return Err(AppError::config(
|
||||
"Already connected or pairing with a signer. Disconnect first.",
|
||||
));
|
||||
}
|
||||
inner.phase = Nip46Phase::Connecting;
|
||||
let signer = self.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = signer
|
||||
.clone()
|
||||
.run_pairing_task(relays, keys, secret, label)
|
||||
.await
|
||||
{
|
||||
signer.fail(e);
|
||||
}
|
||||
});
|
||||
inner.pairing = Some(PairingSession { uri, task });
|
||||
}
|
||||
Ok(self.status().await)
|
||||
}
|
||||
|
||||
/// The pairing background task: listen for the signer's inbound
|
||||
/// `connect` request, echo the secret, persist the connection, then hand
|
||||
/// over to the normal identity handshake + demux loop.
|
||||
async fn run_pairing_task(
|
||||
self,
|
||||
relays: Vec<RelayUrl>,
|
||||
keys: Keys,
|
||||
secret: String,
|
||||
label: String,
|
||||
) -> Result<(), String> {
|
||||
let client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys.clone()))
|
||||
.build();
|
||||
for url in &relays {
|
||||
client
|
||||
.add_relay(url.to_string())
|
||||
.await
|
||||
.map_err(|e| format!("Could not add relay {url}: {e}"))?;
|
||||
}
|
||||
client.connect().and_wait(CONNECT_TIMEOUT).await;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||
let connected = loop {
|
||||
let map = client.relays().all().await;
|
||||
let urls: Vec<String> = map
|
||||
.into_iter()
|
||||
.filter(|(_, relay)| relay.status().is_connected())
|
||||
.map(|(url, _)| url.to_string())
|
||||
.collect();
|
||||
if !urls.is_empty() || tokio::time::Instant::now() >= deadline {
|
||||
break urls;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await
|
||||
};
|
||||
if connected.is_empty() {
|
||||
return Err("None of the relays answered".to_string());
|
||||
}
|
||||
|
||||
// Kind 24133 events tagged to OUR ephemeral pubkey: that is what a
|
||||
// signer replies to after scanning the QR. The author is unknown
|
||||
// until the first event lands.
|
||||
let our_pk = keys.public_key();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::NostrConnect)
|
||||
.tag(Tag::public_key(our_pk));
|
||||
let mut notifications = client.notifications();
|
||||
let subscription = client
|
||||
.subscribe(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Could not subscribe for pairing: {e}"))?;
|
||||
|
||||
let pair_deadline = tokio::time::Instant::now() + PAIRING_TIMEOUT;
|
||||
let (peer, conversation, requested_perms) = loop {
|
||||
let remaining = pair_deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err("Pairing timed out — nobody scanned the code.".to_string());
|
||||
}
|
||||
let incoming = match tokio::time::timeout(remaining, notifications.next()).await {
|
||||
Ok(Some(nostr_sdk::client::ClientNotification::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
..
|
||||
})) if subscription_id == *subscription.id() => event,
|
||||
Ok(Some(nostr_sdk::client::ClientNotification::Shutdown)) | Ok(None) => {
|
||||
return Err("Relay connection closed while pairing".to_string());
|
||||
}
|
||||
Ok(Some(_)) => continue,
|
||||
Err(_elapsed) => {
|
||||
return Err("Pairing timed out — nobody scanned the code.".to_string())
|
||||
}
|
||||
};
|
||||
let event = *incoming;
|
||||
// Never answer ourselves.
|
||||
if event.pubkey == our_pk {
|
||||
continue;
|
||||
}
|
||||
// Not addressed to us unless it decrypts with a conversation
|
||||
// keyed to this author.
|
||||
let Ok(conv) = ConversationKey::derive(keys.secret_key(), &event.pubkey) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(plain) = nip44_decrypt(&conv, &event.content) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(request) = serde_json::from_str::<RawRequest>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
if request.method != "connect" {
|
||||
// Anything before the handshake is premature — ignore.
|
||||
continue;
|
||||
}
|
||||
// NIP-46: the client answers the signer's `connect` with the
|
||||
// secret as the result; the signer verifies the echo.
|
||||
let response = response_ok(&request.id, secret.clone());
|
||||
// Tear down the pairing subscription BEFORE answering: from here
|
||||
// on the demux loop owns the conversation. If both subscriptions
|
||||
// stayed open, a relay could deliver the signer's next message
|
||||
// under the pairing subscription id, where nobody routes it.
|
||||
client.unsubscribe(subscription.id()).await.ok();
|
||||
if let Err(e) = self
|
||||
.publish_payload(&client, &keys, &conv, &event.pubkey, &response)
|
||||
.await
|
||||
{
|
||||
return Err(format!("Could not answer the connect request: {e}"));
|
||||
}
|
||||
// Optional requested_perms ride in params[1]; parse leniently —
|
||||
// a malformed grant list degrades to "no local enforcement",
|
||||
// which defers to the signer's own approval prompts.
|
||||
let perms = request
|
||||
.params
|
||||
.get(1)
|
||||
.and_then(|raw| Nip46Permissions::parse(raw).ok());
|
||||
break Ok::<_, String>((event.pubkey, conv, perms));
|
||||
}?;
|
||||
|
||||
// Paired. Persist the connection row + its secret BEFORE adopting the
|
||||
// identity, so a crash mid-handshake still leaves a recoverable
|
||||
// (if unverified) row, and so `send_rpc` inside the handshake can
|
||||
// find its conversation and peer.
|
||||
let connection = Nip46Connection {
|
||||
profile_npub: None,
|
||||
signer_pubkey: peer.to_hex(),
|
||||
relays: relays.iter().map(|r| r.to_string()).collect(),
|
||||
label,
|
||||
created_at: crate::vault::unix_timestamp().map_err(|e| e.to_string())?,
|
||||
permissions: requested_perms,
|
||||
expires_at: None,
|
||||
revoked_at: None,
|
||||
};
|
||||
{
|
||||
let mut app = self.app.lock().await;
|
||||
app.vault.nip46_connections.retain(|c| {
|
||||
!(c.signer_pubkey == connection.signer_pubkey
|
||||
&& c.profile_npub == connection.profile_npub)
|
||||
});
|
||||
let vault_ref = crate::signer::VaultRef::from_connection(&connection);
|
||||
let vault_key = app.vault_key().copied();
|
||||
crate::vault::store_connection_secret(
|
||||
&mut app.vault,
|
||||
vault_key.as_ref(),
|
||||
&vault_ref,
|
||||
&secret,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
app.vault.nip46_connections.push(connection.clone());
|
||||
app.save_vault().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// The QR has been consumed: drop the pairing session (its URI leaves
|
||||
// status) and move the session state where send_rpc expects it. The
|
||||
// phase stays `Connecting` — identity is still unverified — and the
|
||||
// demux loop (which answers the handshake's RPCs) takes over.
|
||||
let demux_uri = ConnectUri {
|
||||
peer,
|
||||
relays,
|
||||
secret: Some(secret),
|
||||
permissions: connection.permissions.clone(),
|
||||
};
|
||||
let paired = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.pairing.take().is_none() {
|
||||
// disconnect() raced us — tear the fresh session down.
|
||||
Some(())
|
||||
} else {
|
||||
inner.connection = Some(connection);
|
||||
inner.conversation_key = Some(conversation);
|
||||
inner.keys = Some(keys.clone());
|
||||
inner.client = Some(client.clone());
|
||||
inner.identity = None;
|
||||
inner.pending.clear();
|
||||
None
|
||||
}
|
||||
};
|
||||
if paired.is_some() {
|
||||
let _ = client.disconnect().await;
|
||||
return Err("Pairing was cancelled".to_string());
|
||||
}
|
||||
|
||||
let signer = self.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = signer
|
||||
.clone()
|
||||
.run_paired(demux_uri, keys, conversation, client)
|
||||
.await
|
||||
{
|
||||
signer.fail(e);
|
||||
}
|
||||
});
|
||||
self.inner.lock().await.task = Some(task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Post-pairing session body: adopt the signer's identity (learned via
|
||||
/// `get_public_key` — the scanner could present a per-connection comms
|
||||
/// key) while the demux loop answers its RPCs. In this flow WE already
|
||||
/// answered the signer's `connect` with the secret echo, so there is no
|
||||
/// outbound `connect` to send — just identity resolution, then serve.
|
||||
async fn run_paired(
|
||||
self,
|
||||
uri: ConnectUri,
|
||||
keys: Keys,
|
||||
conversation: ConversationKey,
|
||||
client: Client,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let handshake = self.clone();
|
||||
let peer = uri.peer;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handshake.clone().adopt_identity(peer).await {
|
||||
handshake.fail(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.run_demux(uri, keys, conversation, client).await
|
||||
}
|
||||
|
||||
/// Get current connection status.
|
||||
pub async fn status(&self) -> Nip46Status {
|
||||
let inner = self.inner.lock().await;
|
||||
|
|
@ -429,6 +752,11 @@ impl Nip46ClientSigner {
|
|||
_ => None,
|
||||
},
|
||||
pending_approvals: pending,
|
||||
pairing_uri: if matches!(inner.phase, Nip46Phase::Connecting) {
|
||||
inner.pairing.as_ref().map(|p| p.uri.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -649,14 +977,6 @@ impl Nip46ClientSigner {
|
|||
// Update connected relays
|
||||
self.inner.lock().await.client = Some(client.clone());
|
||||
|
||||
// Subscribe to kind 24133 from signer
|
||||
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
|
||||
let mut notifications = client.notifications();
|
||||
let subscription = client
|
||||
.subscribe(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Could not subscribe: {e}"))?;
|
||||
|
||||
// The connect handshake runs as its own task: it publishes `connect`
|
||||
// and then must AWAIT the signer's ack — which can wait on a human
|
||||
// approving us in Amber — followed by `get_public_key` to learn the
|
||||
|
|
@ -675,6 +995,29 @@ impl Nip46ClientSigner {
|
|||
});
|
||||
}
|
||||
|
||||
self.run_demux(uri, keys, conversation, client).await
|
||||
}
|
||||
|
||||
/// The long-lived request/response demux loop, shared by both connect
|
||||
/// directions: bunker:// (we dialed out and spawned the handshake) and
|
||||
/// nostrconnect:// QR pairing (the signer dialed in and we already
|
||||
/// answered its `connect`). Terminates when the relays close or the
|
||||
/// handshake task fails the session.
|
||||
async fn run_demux(
|
||||
self,
|
||||
uri: ConnectUri,
|
||||
keys: Keys,
|
||||
conversation: ConversationKey,
|
||||
client: Client,
|
||||
) -> Result<(), String> {
|
||||
// Subscribe to kind 24133 from signer
|
||||
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
|
||||
let mut notifications = client.notifications();
|
||||
let subscription = client
|
||||
.subscribe(filter)
|
||||
.await
|
||||
.map_err(|e| format!("Could not subscribe: {e}"))?;
|
||||
|
||||
// Handle incoming requests
|
||||
loop {
|
||||
let incoming =
|
||||
|
|
@ -1009,20 +1352,18 @@ impl Nip46ClientSigner {
|
|||
// Resolve the connect secret ON-DEMAND from the vault — the single
|
||||
// source of truth. Fail-closed: if the vault cannot produce the
|
||||
// secret the connect is refused rather than sent incomplete.
|
||||
// (Also snapshot the connection label for the profile row below.)
|
||||
let (secret, label, connect_ref) = {
|
||||
let secret = {
|
||||
let connection = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner.connection.clone()
|
||||
}
|
||||
.ok_or("No active NIP-46 connection to resolve the secret for")?;
|
||||
let label = connection.label.clone();
|
||||
let vault_ref = crate::signer::VaultRef::from_connection(&connection);
|
||||
let app = self.app.lock().await;
|
||||
// Copy the key out before the immutable borrow of the vault so the
|
||||
// two are never borrowed at once.
|
||||
let vault_key = app.vault_key().copied();
|
||||
let secret = match crate::vault::resolve_connection_secret(
|
||||
match crate::vault::resolve_connection_secret(
|
||||
&app.vault,
|
||||
vault_key.as_ref(),
|
||||
&vault_ref,
|
||||
|
|
@ -1034,8 +1375,7 @@ impl Nip46ClientSigner {
|
|||
"Could not resolve the connection secret from the vault: {e}"
|
||||
))
|
||||
}
|
||||
};
|
||||
(secret, label, vault_ref)
|
||||
}
|
||||
};
|
||||
|
||||
let mut params = vec![{
|
||||
|
|
@ -1070,6 +1410,33 @@ impl Nip46ClientSigner {
|
|||
}
|
||||
}
|
||||
|
||||
self.adopt_identity(peer).await
|
||||
}
|
||||
|
||||
/// Resolve the signer's REAL identity and make it the session identity.
|
||||
///
|
||||
/// Shared by both connect directions: after the `connect` handshake is
|
||||
/// acked (bunker:// flow, where WE sent connect) or after we answered the
|
||||
/// signer's inbound `connect` request (nostrconnect:// QR flow). Steps:
|
||||
///
|
||||
/// 1. `get_public_key` — the URI key may be a per-connection comms key
|
||||
/// (Amber mints one per app); only the signer's answer is identity.
|
||||
/// 2. Persist: create/refresh the secretless `Nip46Client` profile row,
|
||||
/// re-key the vault secret store under the identity npub.
|
||||
/// 3. Flip the phase to `Connected` — until then every signing path
|
||||
/// fails closed on an unverified key.
|
||||
async fn adopt_identity(&self, peer: PublicKey) -> Result<(), String> {
|
||||
// Snapshot the pre-identity connection (label + vault ref for the
|
||||
// secret re-key) before touching the App lock.
|
||||
let (connection, label) = {
|
||||
let inner = self.inner.lock().await;
|
||||
let connection = inner
|
||||
.connection
|
||||
.clone()
|
||||
.ok_or("No active NIP-46 connection to adopt an identity for")?;
|
||||
(connection.clone(), connection.label.clone())
|
||||
};
|
||||
|
||||
// Learn the REAL signing identity from the signer itself.
|
||||
let identity = self
|
||||
.send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false)
|
||||
|
|
@ -1090,10 +1457,18 @@ impl Nip46ClientSigner {
|
|||
.map_err(|e| e.message().to_string())?;
|
||||
// Re-key the stored secret under the identity npub so the
|
||||
// connection row, VaultRef, and secret store all agree.
|
||||
let connect_ref = crate::signer::VaultRef::from_connection(&connection);
|
||||
let vault_key = app.vault_key().copied();
|
||||
let secret = crate::vault::resolve_connection_secret(
|
||||
&app.vault,
|
||||
vault_key.as_ref(),
|
||||
&connect_ref,
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(s) = &secret {
|
||||
let new_ref =
|
||||
crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex());
|
||||
let vault_key = app.vault_key().copied();
|
||||
crate::vault::store_connection_secret(
|
||||
&mut app.vault,
|
||||
vault_key.as_ref(),
|
||||
|
|
@ -1114,7 +1489,13 @@ impl Nip46ClientSigner {
|
|||
// Identity verified: adopt it and open the session for signing.
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.identity = Some(identity);
|
||||
inner.active_npub = Some(identity_npub);
|
||||
inner.active_npub = Some(identity_npub.clone());
|
||||
// Keep the in-memory connection in sync with the re-keyed vault row,
|
||||
// so later teardown (disconnect) deletes the secret under the ref it
|
||||
// was actually stored at.
|
||||
if let Some(conn) = inner.connection.as_mut() {
|
||||
conn.profile_npub = Some(identity_npub);
|
||||
}
|
||||
inner.phase = Nip46Phase::Connected;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ pub struct Nip46Status {
|
|||
pub connected_relays: Vec<String>,
|
||||
pub error: Option<String>,
|
||||
pub pending_approvals: Vec<PendingApproval>,
|
||||
/// While pairing (client-initiated flow) this carries the
|
||||
/// `nostrconnect://` URI to render as a QR code for the signer to scan.
|
||||
/// `None` once paired or when not pairing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pairing_uri: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending approval request from the signer.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
//! End-to-end NIP-46 client test: the real `Nip46ClientSigner` connects
|
||||
//! against a local relay and a fake Amber that speaks the bunker:// flow —
|
||||
//! per-connection communication key, delayed human-approval ack, and a real
|
||||
//! identity revealed only via `get_public_key`.
|
||||
//! End-to-end NIP-46 client tests: the real `Nip46ClientSigner` runs against
|
||||
//! a local relay and fake signers — a bunker:// Amber (per-connection comms
|
||||
//! key, delayed human-approval ack) and a QR scanner that consumes a
|
||||
//! client-minted `nostrconnect://` pairing token with secret verification.
|
||||
//! Both reveal a real identity only via `get_public_key`.
|
||||
//!
|
||||
//! Exercises in one process: relay I/O, NIP-44 encryption, the
|
||||
//! deferred-identity handshake, `sign_event` with full verification, and
|
||||
//! vault persistence of the remote profile. No network, no phone.
|
||||
//! Exercises in one process: relay I/O, NIP-44 encryption, both handshake
|
||||
//! directions, the deferred-identity flow, `sign_event` with full
|
||||
//! verification, and vault persistence of the remote profile.
|
||||
//! No network, no phone.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::TcpListener as StdTcpListener;
|
||||
|
|
@ -26,6 +28,10 @@ use serde_json::{json, Value};
|
|||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Vault setup touches the process-global `XDG_DATA_HOME`; serialize it so
|
||||
/// the two e2e tests in this binary cannot read each other's vault.
|
||||
static VAULT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-process nostr relay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -326,6 +332,10 @@ async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
||||
// Isolated vault so the test never touches the real user vault.
|
||||
// XDG_DATA_HOME is process-global and both tests in this binary set it,
|
||||
// so vault setup + App::load are serialized.
|
||||
let app = {
|
||||
let _guard = VAULT_ENV_LOCK.lock().unwrap();
|
||||
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(
|
||||
|
|
@ -334,8 +344,8 @@ async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
|||
)
|
||||
.unwrap();
|
||||
std::env::set_var("XDG_DATA_HOME", &tmp);
|
||||
|
||||
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
|
||||
std::sync::Arc::new(Mutex::new(App::load().expect("load app")))
|
||||
};
|
||||
|
||||
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
|
||||
// `identity` is the REAL signing identity, never in the URI.
|
||||
|
|
@ -448,3 +458,267 @@ async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
|||
signer.disconnect().await.ok();
|
||||
let _ = PublicKey::from_hex; // keep import used across cfg variations
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QR pairing (client-initiated nostrconnect://): the fake signer plays the
|
||||
// scanner role — it reads the pairing token the GUI would render, sends the
|
||||
// `connect` request with the echoed secret, verifies the client's secret
|
||||
// answer, then reveals its identity and signs like Amber.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_fake_scanner(
|
||||
relay_url: String,
|
||||
client_pk: PublicKey,
|
||||
expected_secret: String,
|
||||
identity: Keys,
|
||||
) {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
|
||||
.await
|
||||
.expect("scanner connect");
|
||||
ws.send(Message::Text(
|
||||
json!(["REQ", "scanner", {"kinds": [24133]}])
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let conversation = ConversationKey::derive(identity.secret_key(), &client_pk).unwrap();
|
||||
|
||||
// The scanned URI tells us who to contact and what secret to echo.
|
||||
let connect_req = json!({
|
||||
"id": "pair-1",
|
||||
"method": "connect",
|
||||
"params": [expected_secret.clone()],
|
||||
});
|
||||
let content = nip44_enc(&conversation, &connect_req.to_string());
|
||||
let out = EventBuilder::new(Kind::NostrConnect, content)
|
||||
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
|
||||
.finalize(&identity)
|
||||
.unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!([
|
||||
"EVENT",
|
||||
serde_json::from_str::<Value>(&out.as_json()).unwrap()
|
||||
])
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
while let Some(Ok(msg)) = ws.next().await {
|
||||
let Message::Text(text) = msg else { continue };
|
||||
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
|
||||
continue;
|
||||
};
|
||||
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
|
||||
continue;
|
||||
}
|
||||
let Some(ev) = arr
|
||||
.get(2)
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if ev.pubkey == identity.public_key() {
|
||||
continue;
|
||||
}
|
||||
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
// The client's answer to our connect must carry the secret back —
|
||||
// this is the anti-spoofing check the scanner performs in Amber.
|
||||
if req.get("id").and_then(|v| v.as_str()) == Some("pair-1")
|
||||
&& req.get("result").and_then(|v| v.as_str()) == Some(expected_secret.as_str())
|
||||
{
|
||||
// Secret echoed correctly — the check an actual scanner performs
|
||||
// before approving. Nothing further to do with the ack itself.
|
||||
continue;
|
||||
}
|
||||
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let id = req
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let response: Value = match method {
|
||||
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
|
||||
"sign_event" => {
|
||||
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
|
||||
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
|
||||
Some(mut v) => {
|
||||
if v.get("pubkey").is_none() {
|
||||
v["pubkey"] = json!(identity.public_key().to_hex());
|
||||
}
|
||||
match serde_json::from_value::<UnsignedEvent>(v)
|
||||
.ok()
|
||||
.and_then(|u| identity.sign_event(u).ok())
|
||||
{
|
||||
Some(signed) => json!({"id": id, "result": signed.as_json()}),
|
||||
None => json!({"id": id, "error": "sign failed"}),
|
||||
}
|
||||
}
|
||||
None => json!({"id": id, "error": "bad params"}),
|
||||
}
|
||||
}
|
||||
other => json!({"id": id, "error": format!("unsupported: {other}")}),
|
||||
};
|
||||
|
||||
let content = nip44_enc(&conversation, &response.to_string());
|
||||
let out = EventBuilder::new(Kind::NostrConnect, content)
|
||||
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
|
||||
.finalize(&identity)
|
||||
.unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!([
|
||||
"EVENT",
|
||||
serde_json::from_str::<Value>(&out.as_json()).unwrap()
|
||||
])
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn nip46_qr_pairing_handshake_and_sign() {
|
||||
let relay_url = start_relay().await;
|
||||
|
||||
// Isolated vault + pairing relay, serialized with the other e2e test.
|
||||
let app = {
|
||||
let _guard = VAULT_ENV_LOCK.lock().unwrap();
|
||||
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-pair-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(
|
||||
tmp.join("profiles_vault.json"),
|
||||
serde_json::to_string(&Vault::empty()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var("XDG_DATA_HOME", &tmp);
|
||||
std::sync::Arc::new(Mutex::new(App::load().expect("load app")))
|
||||
};
|
||||
{
|
||||
let mut a = app.lock().await;
|
||||
a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())];
|
||||
a.save_settings().expect("save settings");
|
||||
}
|
||||
|
||||
let identity = Keys::generate();
|
||||
let signer = Nip46ClientSigner::new(app.clone());
|
||||
|
||||
// Start pairing: we get back the token the GUI renders as a QR.
|
||||
let status = signer
|
||||
.start_pairing("qr pairing test".to_string())
|
||||
.await
|
||||
.expect("start pairing");
|
||||
assert!(!status.connected, "not connected until someone scans");
|
||||
let pairing_uri = status.pairing_uri.clone().expect("pairing URI present");
|
||||
assert!(
|
||||
pairing_uri.starts_with("nostrconnect://"),
|
||||
"pairing token must be a nostrconnect:// URI"
|
||||
);
|
||||
|
||||
// The token must be a well-formed client-initiated URI: ephemeral
|
||||
// authority key, our relay, and the anti-spoofing secret.
|
||||
let parsed = nostr::nips::nip46::NostrConnectUri::parse(&pairing_uri)
|
||||
.expect("pairing URI parses with the same parser real signers use");
|
||||
let nostr::nips::nip46::NostrConnectUri::Client {
|
||||
public_key: client_pk,
|
||||
secret,
|
||||
relays,
|
||||
..
|
||||
} = parsed
|
||||
else {
|
||||
panic!("pairing URI must be the client variant");
|
||||
};
|
||||
assert_eq!(relays.len(), 1);
|
||||
assert!(!secret.is_empty());
|
||||
|
||||
// The scanner (Amber role) consumes the token.
|
||||
tokio::spawn(run_fake_scanner(
|
||||
relay_url.clone(),
|
||||
client_pk,
|
||||
secret.clone(),
|
||||
identity.clone(),
|
||||
));
|
||||
|
||||
// Wait for scan -> secret echo -> identity adoption.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
let status = signer.status().await;
|
||||
if let Some(err) = &status.error {
|
||||
panic!("pairing failed: {err}");
|
||||
}
|
||||
if status.connected {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"pairing never completed; last status: {:?}",
|
||||
signer.status().await
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// The QR token is single-use: consumed, so it no longer appears.
|
||||
assert!(
|
||||
signer.status().await.pairing_uri.is_none(),
|
||||
"pairing URI must be dropped once scanned"
|
||||
);
|
||||
|
||||
// Identity came from get_public_key, not from the URI authority key.
|
||||
let resolved = SignerTrait::get_public_key(&signer)
|
||||
.await
|
||||
.expect("identity resolved");
|
||||
assert_eq!(resolved, identity.public_key());
|
||||
assert_ne!(
|
||||
resolved, client_pk,
|
||||
"ephemeral pairing key must never become identity"
|
||||
);
|
||||
|
||||
// Sign through the paired signer.
|
||||
let unsigned = UnsignedEvent::new(
|
||||
identity.public_key(),
|
||||
Timestamp::now(),
|
||||
Kind::TextNote,
|
||||
vec![],
|
||||
"paired via QR".to_string(),
|
||||
);
|
||||
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
|
||||
.await
|
||||
.expect("remote sign_event after pairing");
|
||||
assert_eq!(signed.pubkey, identity.public_key());
|
||||
assert_eq!(signed.content, "paired via QR");
|
||||
assert!(signed.verify_signature());
|
||||
|
||||
// Vault persistence: remote profile under the real identity, no secrets.
|
||||
let identity_npub = identity.public_key().to_bech32().unwrap();
|
||||
let app_guard = app.lock().await;
|
||||
let profile = app_guard
|
||||
.vault
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| p.public_key == identity_npub)
|
||||
.expect("remote profile row created by pairing");
|
||||
assert_eq!(
|
||||
profile.signer_mode,
|
||||
keynectr::vault::SignerMode::Nip46Client
|
||||
);
|
||||
assert!(
|
||||
profile.secret_key.trim().is_empty(),
|
||||
"no secret material for remote profiles"
|
||||
);
|
||||
drop(app_guard);
|
||||
|
||||
signer.disconnect().await.ok();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue