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:
Avi 2026-09-12 04:47:20 -05:00
commit 38499d4506
12 changed files with 1041 additions and 49 deletions

1
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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",

View file

@ -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",

View file

@ -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) =>

View file

@ -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. */

View file

@ -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()}
> >

View file

@ -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,

View file

@ -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 {

View file

@ -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(())
} }

View file

@ -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.

View file

@ -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();
}