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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
|
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes 0.8.4",
|
||||||
"base64",
|
"base64",
|
||||||
"bech32",
|
"bech32",
|
||||||
"bip39",
|
"bip39",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr = { version = "0.45", features = ["nip44", "nip98"] }
|
nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] }
|
||||||
nostr-sdk = "0.45"
|
nostr-sdk = "0.45"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nostr-tools": "^2.25.1",
|
"nostr-tools": "^2.25.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
|
@ -1537,6 +1539,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
|
|
@ -2028,7 +2040,6 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -2038,7 +2049,6 @@
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
|
|
@ -2516,6 +2526,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
|
@ -2608,7 +2627,6 @@
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
|
|
@ -2621,7 +2639,6 @@
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
|
@ -2898,6 +2924,12 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/dir-compare": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||||
|
|
@ -3187,7 +3219,6 @@
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
|
|
@ -3761,7 +3792,6 @@
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
|
@ -4218,7 +4248,6 @@
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -5250,6 +5279,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -5280,7 +5318,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -5394,6 +5431,15 @@
|
||||||
"node": ">=10.4.0"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
|
@ -5601,6 +5647,141 @@
|
||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/quick-lru": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||||
|
|
@ -5693,7 +5874,6 @@
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -5709,6 +5889,12 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resedit": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||||
|
|
@ -5936,6 +6122,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -6063,7 +6255,6 @@
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
|
|
@ -6078,7 +6269,6 @@
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
|
|
@ -6764,6 +6954,12 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nostr-tools": "^2.25.1",
|
"nostr-tools": "^2.25.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ export const api = {
|
||||||
// NIP-46 client signer
|
// NIP-46 client signer
|
||||||
nip46Connect: (uri: string, label: string) =>
|
nip46Connect: (uri: string, label: string) =>
|
||||||
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
|
||||||
|
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
|
||||||
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
|
||||||
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
|
||||||
nip46Approve: (id: string, approved: boolean) =>
|
nip46Approve: (id: string, approved: boolean) =>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ export interface Nip46SignerStatus {
|
||||||
connected_relays: string[];
|
connected_relays: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
pending_approvals: PendingApproval[];
|
pending_approvals: PendingApproval[];
|
||||||
|
/** nostrconnect:// pairing token while a QR pairing is in flight. */
|
||||||
|
pairing_uri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union of all signer statuses. */
|
/** Union of all signer statuses. */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import { Alert } from '../components/Alert';
|
import { Alert } from '../components/Alert';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
|
|
@ -14,6 +15,7 @@ export function SignerModeScreen() {
|
||||||
embeddedSignerStatus,
|
embeddedSignerStatus,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
embeddedSignerApprove,
|
embeddedSignerApprove,
|
||||||
|
|
@ -30,6 +32,8 @@ export function SignerModeScreen() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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)
|
// Single source of truth: backend state (defaults to most secure)
|
||||||
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
|
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
|
||||||
|
|
@ -148,6 +152,58 @@ export function SignerModeScreen() {
|
||||||
}
|
}
|
||||||
}, [uri, label, nip46Connect]);
|
}, [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 () => {
|
const handleNip46Disconnect = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -505,6 +561,44 @@ export function SignerModeScreen() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|
@ -522,7 +616,7 @@ export function SignerModeScreen() {
|
||||||
/>
|
/>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
{mode === 'nip46_client'
|
{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.'}
|
: 'Share this with client apps that want to connect to this bunker.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -535,10 +629,20 @@ export function SignerModeScreen() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <ErrorText>{error}</ErrorText>}
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
{pairError && <ErrorText>{pairError}</ErrorText>}
|
||||||
<div className="settings-inline">
|
<div className="settings-inline">
|
||||||
|
{mode === 'nip46_client' && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
loading={connecting}
|
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()}
|
disabled={!uri.trim()}
|
||||||
onClick={() => void handleNip46Connect()}
|
onClick={() => void handleNip46Connect()}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ interface AppContextValue {
|
||||||
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
|
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
|
||||||
// NIP-46 client signer
|
// NIP-46 client signer
|
||||||
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
|
||||||
|
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
|
||||||
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
nip46Disconnect: () => Promise<Nip46SignerStatus>;
|
||||||
nip46Status: () => Promise<Nip46SignerStatus>;
|
nip46Status: () => Promise<Nip46SignerStatus>;
|
||||||
nip46Approve: (id: string, approved: boolean) => 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 nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
|
||||||
const nip46Status = useCallback(() => api.nip46Status(), []);
|
const nip46Status = useCallback(() => api.nip46Status(), []);
|
||||||
|
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
|
||||||
const nip46Approve = useCallback(
|
const nip46Approve = useCallback(
|
||||||
(id: string, approved: boolean) => api.nip46Approve(id, approved),
|
(id: string, approved: boolean) => api.nip46Approve(id, approved),
|
||||||
[],
|
[],
|
||||||
|
|
@ -350,6 +352,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
embeddedSignerStatus,
|
embeddedSignerStatus,
|
||||||
embeddedSignerApprove,
|
embeddedSignerApprove,
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
|
|
@ -407,6 +410,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
embeddedSignerStatus,
|
embeddedSignerStatus,
|
||||||
embeddedSignerApprove,
|
embeddedSignerApprove,
|
||||||
nip46Connect,
|
nip46Connect,
|
||||||
|
nip46PairStart,
|
||||||
nip46Disconnect,
|
nip46Disconnect,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
|
|
|
||||||
22
src/ipc.rs
22
src/ipc.rs
|
|
@ -165,6 +165,12 @@ pub enum Request {
|
||||||
uri: String,
|
uri: String,
|
||||||
label: 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.
|
/// Disconnect from the NIP-46 signer.
|
||||||
Nip46Disconnect,
|
Nip46Disconnect,
|
||||||
/// Get NIP-46 connection status.
|
/// 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?;
|
let status = signer.connect(&uri, label).await?;
|
||||||
Ok(json!(status))
|
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 => {
|
Request::Nip46Disconnect => {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
if let Some(signer) = &guard.nip46_signer {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use base64::Engine;
|
||||||
use getrandom::getrandom;
|
use getrandom::getrandom;
|
||||||
use nostr::nips::nip44::v2;
|
use nostr::nips::nip44::v2;
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
|
use nostr::nips::nip46::NostrConnectUri;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
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
|
/// 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.
|
/// for the remote signer's response before it is abandoned.
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
/// The connect handshake can involve a human approving the app on the
|
/// How long the connect handshake can involve a human approving the app on
|
||||||
/// signer's screen, so it gets a far longer leash than ordinary RPCs.
|
/// the signer's screen, so it gets a far longer leash than ordinary RPCs.
|
||||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(120);
|
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.
|
/// Internal state for a pending approval.
|
||||||
struct PendingApprovalInner {
|
struct PendingApprovalInner {
|
||||||
|
|
@ -45,6 +51,16 @@ struct PendingApprovalInner {
|
||||||
sender: oneshot::Sender<ApprovalResult>,
|
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
|
/// 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).
|
/// (the client half of the NIP-46 flow, e.g. our `sign_event` request).
|
||||||
struct PendingRemoteRequest {
|
struct PendingRemoteRequest {
|
||||||
|
|
@ -77,6 +93,8 @@ struct Nip46Inner {
|
||||||
remote_pending: HashMap<String, PendingRemoteRequest>,
|
remote_pending: HashMap<String, PendingRemoteRequest>,
|
||||||
keys: Option<Keys>,
|
keys: Option<Keys>,
|
||||||
active_npub: Option<String>,
|
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
|
/// The remote signer's REAL identity key, learned from the
|
||||||
/// `get_public_key` RPC after the connect handshake completes. The
|
/// `get_public_key` RPC after the connect handshake completes. The
|
||||||
/// pubkey in the connect URI may be a per-connection communication key
|
/// pubkey in the connect URI may be a per-connection communication key
|
||||||
|
|
@ -108,6 +126,7 @@ impl Nip46ClientSigner {
|
||||||
remote_pending: HashMap::new(),
|
remote_pending: HashMap::new(),
|
||||||
keys: None,
|
keys: None,
|
||||||
active_npub: None,
|
active_npub: None,
|
||||||
|
pairing: None,
|
||||||
identity: None,
|
identity: None,
|
||||||
})),
|
})),
|
||||||
app,
|
app,
|
||||||
|
|
@ -355,6 +374,9 @@ impl Nip46ClientSigner {
|
||||||
if let Some(task) = inner.task.take() {
|
if let Some(task) = inner.task.take() {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
|
if let Some(pairing) = inner.pairing.take() {
|
||||||
|
pairing.task.abort();
|
||||||
|
}
|
||||||
if let Some(client) = inner.client.take() {
|
if let Some(client) = inner.client.take() {
|
||||||
let _ = client.disconnect().await;
|
let _ = client.disconnect().await;
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +413,307 @@ impl Nip46ClientSigner {
|
||||||
self.disconnect().await
|
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.
|
/// Get current connection status.
|
||||||
pub async fn status(&self) -> Nip46Status {
|
pub async fn status(&self) -> Nip46Status {
|
||||||
let inner = self.inner.lock().await;
|
let inner = self.inner.lock().await;
|
||||||
|
|
@ -429,6 +752,11 @@ impl Nip46ClientSigner {
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
pending_approvals: pending,
|
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
|
// Update connected relays
|
||||||
self.inner.lock().await.client = Some(client.clone());
|
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`
|
// 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
|
// 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
|
// 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
|
// Handle incoming requests
|
||||||
loop {
|
loop {
|
||||||
let incoming =
|
let incoming =
|
||||||
|
|
@ -1009,20 +1352,18 @@ impl Nip46ClientSigner {
|
||||||
// Resolve the connect secret ON-DEMAND from the vault — the single
|
// Resolve the connect secret ON-DEMAND from the vault — the single
|
||||||
// source of truth. Fail-closed: if the vault cannot produce the
|
// source of truth. Fail-closed: if the vault cannot produce the
|
||||||
// secret the connect is refused rather than sent incomplete.
|
// secret the connect is refused rather than sent incomplete.
|
||||||
// (Also snapshot the connection label for the profile row below.)
|
let secret = {
|
||||||
let (secret, label, connect_ref) = {
|
|
||||||
let connection = {
|
let connection = {
|
||||||
let inner = self.inner.lock().await;
|
let inner = self.inner.lock().await;
|
||||||
inner.connection.clone()
|
inner.connection.clone()
|
||||||
}
|
}
|
||||||
.ok_or("No active NIP-46 connection to resolve the secret for")?;
|
.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 vault_ref = crate::signer::VaultRef::from_connection(&connection);
|
||||||
let app = self.app.lock().await;
|
let app = self.app.lock().await;
|
||||||
// Copy the key out before the immutable borrow of the vault so the
|
// Copy the key out before the immutable borrow of the vault so the
|
||||||
// two are never borrowed at once.
|
// two are never borrowed at once.
|
||||||
let vault_key = app.vault_key().copied();
|
let vault_key = app.vault_key().copied();
|
||||||
let secret = match crate::vault::resolve_connection_secret(
|
match crate::vault::resolve_connection_secret(
|
||||||
&app.vault,
|
&app.vault,
|
||||||
vault_key.as_ref(),
|
vault_key.as_ref(),
|
||||||
&vault_ref,
|
&vault_ref,
|
||||||
|
|
@ -1034,8 +1375,7 @@ impl Nip46ClientSigner {
|
||||||
"Could not resolve the connection secret from the vault: {e}"
|
"Could not resolve the connection secret from the vault: {e}"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
(secret, label, vault_ref)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut params = vec![{
|
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.
|
// Learn the REAL signing identity from the signer itself.
|
||||||
let identity = self
|
let identity = self
|
||||||
.send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false)
|
.send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false)
|
||||||
|
|
@ -1090,10 +1457,18 @@ impl Nip46ClientSigner {
|
||||||
.map_err(|e| e.message().to_string())?;
|
.map_err(|e| e.message().to_string())?;
|
||||||
// Re-key the stored secret under the identity npub so the
|
// Re-key the stored secret under the identity npub so the
|
||||||
// connection row, VaultRef, and secret store all agree.
|
// 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 {
|
if let Some(s) = &secret {
|
||||||
let new_ref =
|
let new_ref =
|
||||||
crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex());
|
crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex());
|
||||||
let vault_key = app.vault_key().copied();
|
|
||||||
crate::vault::store_connection_secret(
|
crate::vault::store_connection_secret(
|
||||||
&mut app.vault,
|
&mut app.vault,
|
||||||
vault_key.as_ref(),
|
vault_key.as_ref(),
|
||||||
|
|
@ -1114,7 +1489,13 @@ impl Nip46ClientSigner {
|
||||||
// Identity verified: adopt it and open the session for signing.
|
// Identity verified: adopt it and open the session for signing.
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
inner.identity = Some(identity);
|
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;
|
inner.phase = Nip46Phase::Connected;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,11 @@ pub struct Nip46Status {
|
||||||
pub connected_relays: Vec<String>,
|
pub connected_relays: Vec<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub pending_approvals: Vec<PendingApproval>,
|
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.
|
/// A pending approval request from the signer.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
//! End-to-end NIP-46 client test: the real `Nip46ClientSigner` connects
|
//! End-to-end NIP-46 client tests: the real `Nip46ClientSigner` runs against
|
||||||
//! against a local relay and a fake Amber that speaks the bunker:// flow —
|
//! a local relay and fake signers — a bunker:// Amber (per-connection comms
|
||||||
//! per-connection communication key, delayed human-approval ack, and a real
|
//! key, delayed human-approval ack) and a QR scanner that consumes a
|
||||||
//! identity revealed only via `get_public_key`.
|
//! 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
|
//! Exercises in one process: relay I/O, NIP-44 encryption, both handshake
|
||||||
//! deferred-identity handshake, `sign_event` with full verification, and
|
//! directions, the deferred-identity flow, `sign_event` with full
|
||||||
//! vault persistence of the remote profile. No network, no phone.
|
//! verification, and vault persistence of the remote profile.
|
||||||
|
//! No network, no phone.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::TcpListener as StdTcpListener;
|
use std::net::TcpListener as StdTcpListener;
|
||||||
|
|
@ -26,6 +28,10 @@ use serde_json::{json, Value};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
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
|
// 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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
||||||
// Isolated vault so the test never touches the real user vault.
|
// 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()));
|
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
|
|
@ -334,8 +344,8 @@ async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::env::set_var("XDG_DATA_HOME", &tmp);
|
std::env::set_var("XDG_DATA_HOME", &tmp);
|
||||||
|
std::sync::Arc::new(Mutex::new(App::load().expect("load app")))
|
||||||
let 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;
|
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
|
||||||
// `identity` is the REAL signing identity, never in the 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();
|
signer.disconnect().await.ok();
|
||||||
let _ = PublicKey::from_hex; // keep import used across cfg variations
|
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