From ef66092727824d94ad6e2c709d8970bb3a1fa18f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:01:39 -0400 Subject: [PATCH] fix(nip46): implement the nostrconnect:// (QR) connect-link login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-46 login dialog is purely the QR / connect-link flow ("scan with Amber/ NsecBunker"), but it failed with "A remote signer pubkey is required." A nostrconnect:// link carries the CLIENT pubkey; the remote signer's pubkey is only learned after it scans the QR and acknowledges — and the active connect path (SignerManager) only supported bunker:// (pubkey already in the URI). The "wait for the signer to acknowledge" logic existed only on the unused Nip46Connector. Port it into SignerManager as _waitForRemoteSignerHandshake: subscribe to kind-24133 events addressed to the client pubkey, decrypt the signer's response, match the handshake secret (or a generic ack), surface auth_url challenges, and resolve with the signer's pubkey (with a timeout + a cancel hook wired into disconnectRemoteSigner). connectRemoteSigner now performs this wait for client-initiated (nostrconnect) connections before constructing the RPC client; bunker:// is unchanged. Decryption uses the existing attemptDecryptNip46HandshakePayload (injectable via _decryptHandshakePayload for testing). Test: tests/nostr/nip46-wait-handshake.test.mjs (secret match resolves with signer pubkey; wrong secret ignored -> timeout; auth_url surfaced without resolving). Full suite green. Co-Authored-By: Claude Opus 4.8 --- js/nostr/managers/SignerManager.js | 239 +++++++++++++++++++++- tests/nostr/nip46-wait-handshake.test.mjs | 112 ++++++++++ 2 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 tests/nostr/nip46-wait-handshake.test.mjs diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 7271bad2..1f3323cf 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -39,6 +39,10 @@ import { resolveNip46Relays, encodeHexToNpub, generateNip46Secret, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + attemptDecryptNip46HandshakePayload, + normalizeNostrPubkey, } from "../nip46Client.js"; import { @@ -740,6 +744,203 @@ export class SignerManager { } } + /** + * Waits for a remote signer to acknowledge a `nostrconnect://` handshake. + * Subscribes to NIP-46 RPC events addressed to the client pubkey; the first + * acknowledgement (matching the handshake secret, or a generic ack) reveals + * the remote signer's pubkey, which is needed before the RPC client can be + * constructed. Ported from the (unused) Nip46Connector. + * + * @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>} + */ + async _waitForRemoteSignerHandshake({ + clientPrivateKey, + clientPublicKey, + relays, + secret, + onAuthUrl, + onStatus, + timeoutMs, + } = {}) { + const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey); + if (!normalizedClientPublicKey) { + throw new Error( + "A client public key is required to await the remote signer handshake.", + ); + } + if ( + !clientPrivateKey || + typeof clientPrivateKey !== "string" || + !HEX64_REGEX.test(clientPrivateKey) + ) { + throw new Error( + "A client private key is required to await the remote signer handshake.", + ); + } + + const resolvedRelays = resolveNip46Relays(relays, this.client?.relays); + if (!resolvedRelays.length) { + throw new Error("No relays available to complete the remote signer handshake."); + } + + const pool = await this.client.ensurePool(); + const filters = [ + { kinds: [NIP46_RPC_KIND], "#p": [normalizedClientPublicKey] }, + ]; + const waitTimeout = + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : NIP46_HANDSHAKE_TIMEOUT_MS; + + const coerceStructuredString = (value) => { + if (typeof value === "string") { + return value.trim(); + } + if (Array.isArray(value)) { + for (const entry of value) { + const candidate = coerceStructuredString(entry); + if (candidate) return candidate; + } + return ""; + } + if (value && typeof value === "object") { + for (const key of ["secret", "message", "status", "result", "url"]) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const candidate = coerceStructuredString(value[key]); + if (candidate) return candidate; + } + } + } + return ""; + }; + + return new Promise((resolve, reject) => { + let settled = false; + let subscription; + let timeoutId; + const cleanup = () => { + if (settled) return; + settled = true; + this.pendingHandshakeCancel = null; + try { + subscription?.unsub?.(); + } catch (error) { + // ignore subscription cleanup failures + } + if (timeoutId) clearTimeout(timeoutId); + }; + + // Allow the UI (modal close / disconnect) to abort the wait. + this.pendingHandshakeCancel = () => { + cleanup(); + const error = new Error("Remote signer connection cancelled."); + error.code = "login-cancelled"; + reject(error); + }; + + timeoutId = setTimeout(() => { + cleanup(); + const error = new Error( + "Timed out waiting for the remote signer to acknowledge the connection.", + ); + error.code = "nip46-handshake-timeout"; + reject(error); + }, waitTimeout); + + try { + subscription = pool.sub(resolvedRelays, filters); + } catch (error) { + cleanup(); + reject(error); + return; + } + + subscription.on("event", (event) => { + if (settled || !event || event.kind !== NIP46_RPC_KIND) { + return; + } + const eventRemotePubkey = normalizeNostrPubkey(event.pubkey); + const candidateRemotePubkeys = eventRemotePubkey ? [eventRemotePubkey] : []; + + const decryptHandshake = + this._decryptHandshakePayload || attemptDecryptNip46HandshakePayload; + Promise.resolve() + .then(() => + decryptHandshake({ + clientPrivateKey, + candidateRemotePubkeys, + ciphertext: event.content, + }), + ) + .then((payloadResult) => { + let parsed; + try { + parsed = JSON.parse(payloadResult?.plaintext ?? ""); + } catch (error) { + return; + } + + const resultValue = coerceStructuredString(parsed?.result); + const errorValue = coerceStructuredString(parsed?.error); + + // auth_url challenge: surface to the UI and keep waiting. + if (resultValue === "auth_url" && errorValue) { + if (typeof onAuthUrl === "function") { + try { + onAuthUrl(errorValue, { + phase: "handshake", + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + requestId: typeof parsed?.id === "string" ? parsed.id : "", + }); + } catch (callbackError) { + devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError); + } + } + return; + } + + // Match the handshake secret (or accept a generic ack). + if (secret) { + const normalizedResult = resultValue ? resultValue.toLowerCase() : ""; + if (resultValue !== secret && normalizedResult !== "ack") return; + } else if (resultValue) { + if (!["ack", "ok", "success"].includes(resultValue.toLowerCase())) return; + } + + cleanup(); + if (typeof onStatus === "function") { + try { + onStatus({ + phase: "handshake", + state: "acknowledged", + message: "Remote signer acknowledged the connect request.", + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + }); + } catch (callbackError) { + devLogger.warn("[nostr] Handshake status callback threw:", callbackError); + } + } + resolve({ + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + eventPubkey: eventRemotePubkey || "", + response: parsed, + algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), + }); + }) + .catch((error) => { + devLogger.warn( + "[nostr] Failed to decrypt remote signer handshake payload:", + error, + ); + }); + }); + + subscription.on("eose", () => { + // no-op: handshake responses are push-based + }); + }); + } + async connectRemoteSigner({ connectionString, remember = true, @@ -833,11 +1034,39 @@ export class SignerManager { await this.client.ensurePool(); + // A nostrconnect:// link is client-initiated: the remote signer's pubkey is + // unknown until the user's signer scans the QR / opens the link and + // acknowledges. Wait for that ACK to learn the remote pubkey before building + // the RPC client (which requires it). bunker:// URIs already carry it. + let remotePubkey = parsed.remotePubkey; + if (!remotePubkey && parsed.type === "client") { + const clientPublicKey = + providedClientPublicKey || parsed.clientPublicKey || ""; + handleStatus({ + phase: "handshake", + state: "waiting", + message: "Waiting for your signer to approve the connection…", + }); + const ack = await this._waitForRemoteSignerHandshake({ + clientPrivateKey, + clientPublicKey, + relays: resolveNip46Relays(parsed.relays, providedRelays), + secret: providedSecret || parsed.secret, + onAuthUrl: handleAuthChallenge, + onStatus: handleStatus, + timeoutMs: handshakeTimeoutMs, + }); + remotePubkey = ack.remotePubkey; + if (!remotePubkey) { + throw new Error("Remote signer did not provide a usable pubkey."); + } + } + this.nip46Client = new Nip46RpcClient({ nostrClient: this.client, relays: resolveNip46Relays(parsed.relays, providedRelays), clientPrivateKey: clientPrivateKey, - remotePubkey: parsed.remotePubkey, + remotePubkey, secret: providedSecret || parsed.secret, signEvent: signEventWithPrivateKey, }); @@ -1002,6 +1231,14 @@ export class SignerManager { } async disconnectRemoteSigner({ keepStored = false } = {}) { + // Abort an in-flight nostrconnect:// handshake wait, if any. + if (typeof this.pendingHandshakeCancel === "function") { + try { + this.pendingHandshakeCancel(); + } catch (error) { + // ignore cancel errors + } + } if (this.nip46Client) { if (typeof this.nip46Client.destroy === 'function') { this.nip46Client.destroy(); diff --git a/tests/nostr/nip46-wait-handshake.test.mjs b/tests/nostr/nip46-wait-handshake.test.mjs new file mode 100644 index 00000000..19516a0d --- /dev/null +++ b/tests/nostr/nip46-wait-handshake.test.mjs @@ -0,0 +1,112 @@ +// Scenario (SCN-nip46-wait-handshake): +// The nostrconnect:// (QR) login path must WAIT for the remote signer to scan +// and acknowledge before it can know the signer's pubkey. _waitForRemoteSignerHandshake +// subscribes to NIP-46 RPC events addressed to the client pubkey and resolves +// with the signer's pubkey on a matching ACK (secret match), surfaces auth_url +// challenges without resolving, and times out otherwise. (Decryption itself is +// existing/tested code, injected here so we exercise the wait/match logic.) + +import "../test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; + +globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true; +const { SignerManager } = await import("../../js/nostr/managers/SignerManager.js"); + +const CLIENT_PUB = "a".repeat(64); +const CLIENT_PRIV = "b".repeat(64); +const SIGNER_PUB = "c".repeat(64); + +// A fake pool whose sub() exposes the captured event handler so the test can +// simulate the remote signer publishing an acknowledgement. +function makeManagerWithPool() { + const captured = { handler: null, unsubbed: false, filters: null }; + const pool = { + sub(relays, filters) { + captured.filters = filters; + return { + on(ev, cb) { + if (ev === "event") captured.handler = cb; + }, + unsub() { + captured.unsubbed = true; + }, + }; + }, + }; + const client = { relays: ["wss://relay.one"], ensurePool: async () => pool }; + const mgr = new SignerManager(client); + return { mgr, captured }; +} + +const fireEvent = (captured, content) => + captured.handler({ kind: 24133, pubkey: SIGNER_PUB, content, id: "evt1" }); + +test("resolves with the signer pubkey when the ACK matches the secret", async () => { + const { mgr, captured } = makeManagerWithPool(); + const secret = "handshake-secret-xyz"; + // Inject the decryptor: pretend the event decrypts to a matching ACK. + mgr._decryptHandshakePayload = async () => ({ + plaintext: JSON.stringify({ id: "1", result: secret }), + remotePubkey: SIGNER_PUB, + algorithm: "nip44", + }); + + const promise = mgr._waitForRemoteSignerHandshake({ + clientPrivateKey: CLIENT_PRIV, + clientPublicKey: CLIENT_PUB, + relays: ["wss://relay.one"], + secret, + timeoutMs: 5000, + }); + + await new Promise((r) => setTimeout(r, 5)); // let the subscription register + assert.ok(captured.handler, "subscribed and registered an event handler"); + assert.deepEqual(captured.filters, [{ kinds: [24133], "#p": [CLIENT_PUB] }]); + fireEvent(captured, "ciphertext"); + + const result = await promise; + assert.equal(result.remotePubkey, SIGNER_PUB); + assert.equal(captured.unsubbed, true, "unsubscribes after resolving"); +}); + +test("ignores a non-matching secret and eventually times out", async () => { + const { mgr, captured } = makeManagerWithPool(); + mgr._decryptHandshakePayload = async () => ({ + plaintext: JSON.stringify({ result: "WRONG-secret" }), + remotePubkey: SIGNER_PUB, + }); + const promise = mgr._waitForRemoteSignerHandshake({ + clientPrivateKey: CLIENT_PRIV, + clientPublicKey: CLIENT_PUB, + relays: ["wss://relay.one"], + secret: "the-real-secret", + timeoutMs: 80, + }); + await new Promise((r) => setTimeout(r, 5)); + fireEvent(captured, "ciphertext"); // wrong secret => ignored + await assert.rejects(promise, (e) => e.code === "nip46-handshake-timeout"); +}); + +test("surfaces an auth_url challenge without resolving", async () => { + const { mgr, captured } = makeManagerWithPool(); + let authUrl = ""; + mgr._decryptHandshakePayload = async () => ({ + plaintext: JSON.stringify({ result: "auth_url", error: "https://signer.example/approve" }), + remotePubkey: SIGNER_PUB, + }); + const promise = mgr._waitForRemoteSignerHandshake({ + clientPrivateKey: CLIENT_PRIV, + clientPublicKey: CLIENT_PUB, + relays: ["wss://relay.one"], + secret: "s", + timeoutMs: 80, + onAuthUrl: (url) => { + authUrl = url; + }, + }); + await new Promise((r) => setTimeout(r, 5)); + fireEvent(captured, "ciphertext"); + await assert.rejects(promise, (e) => e.code === "nip46-handshake-timeout"); + assert.equal(authUrl, "https://signer.example/approve", "auth_url surfaced to the UI"); +});