diff --git a/docs/nip46-remote-signers.md b/docs/nip46-remote-signers.md index 5b486454..3423f3b2 100644 --- a/docs/nip46-remote-signers.md +++ b/docs/nip46-remote-signers.md @@ -36,6 +36,18 @@ URI manually when QR pairing isn’t available. These URIs can advertise the sam `relay=`, `secret=`, `perms=`, `name`, `url`, and `image` parameters described above. If no relays are specified the default bundle listed earlier is used. +Recent remote signers also append a `remote-signer-key=` parameter that +identifies the key the bunker uses for encrypted RPC traffic. The hostname/path +may now refer to the *user* pubkey instead of the signer key, so clients must: + +1. Decrypt handshake acknowledgements with the `remote-signer-key` when + provided, falling back to the event author as a legacy escape hatch. +2. Continue calling `get_public_key` after the `connect` RPC to learn the user + pubkey rather than trusting the bunker URI host. + +bitvid automatically persists both the signer key and the user pubkey returned +by `get_public_key` so reconnect flows display accurate metadata. + ## Relay requirements The remote signer must subscribe to `kind 24133` events targeted at the client diff --git a/js/nostr.js b/js/nostr.js index 2c0f596a..784edb56 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -762,6 +762,8 @@ export const __testExports = { decryptNip46PayloadWithKeys, createNip46Cipher, normalizeNip46CiphertextPayload, + parseNip46ConnectionString, + attemptDecryptNip46HandshakePayload, }; function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") { @@ -3556,24 +3558,31 @@ function parseNip46ConnectionString(uri) { return null; } + const decodeParam = (raw) => { + if (typeof raw !== "string") { + return ""; + } + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + try { + return decodeURIComponent(trimmed); + } catch (error) { + return trimmed; + } + }; + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); const params = parsed.searchParams || new URLSearchParams(); const relays = params .getAll("relay") - .map((relay) => { - if (typeof relay !== "string") { - return ""; - } - try { - return decodeURIComponent(relay.trim()); - } catch (error) { - return relay.trim(); - } - }) + .map((relay) => decodeParam(relay)) .filter(Boolean); - const permissionsParam = params.get("perms") || params.get("permissions") || ""; + const permissionsParam = + decodeParam(params.get("perms")) || decodeParam(params.get("permissions")) || ""; const metadata = { name: "", url: "", @@ -3581,37 +3590,86 @@ function parseNip46ConnectionString(uri) { }; for (const key of ["name", "url", "image"]) { - const raw = params.get(key); - if (typeof raw === "string" && raw.trim()) { - try { - metadata[key] = decodeURIComponent(raw.trim()); - } catch (error) { - metadata[key] = raw.trim(); - } + const decoded = decodeParam(params.get(key)); + if (decoded) { + metadata[key] = decoded; } } - let remotePubkey = ""; + const remoteSignerParamKeys = [ + "remote-signer-key", + "remote_signer_key", + "remotesignerkey", + "remoteSignerKey", + "remote-signer-pubkey", + "remote_signer_pubkey", + "remoteSignerPubkey", + ]; + + let remoteSignerParam = ""; + for (const key of remoteSignerParamKeys) { + const candidate = decodeParam(params.get(key)); + if (candidate) { + remoteSignerParam = candidate; + break; + } + } + + const remoteFallbackParam = + decodeParam(params.get("remote")) || + decodeParam(params.get("remotePubkey")) || + decodeParam(params.get("signer")) || + ""; + + let remotePubkey = remoteSignerParam || remoteFallbackParam || ""; let clientPubkey = ""; + let userPubkeyHint = ""; if (scheme === "bunker") { - remotePubkey = parsed.hostname || ""; - if (!remotePubkey && parsed.pathname && parsed.pathname !== "/") { - remotePubkey = parsed.pathname.replace(/^\/+/, ""); + let bunkerIdentifier = parsed.hostname || ""; + if (!bunkerIdentifier && parsed.pathname && parsed.pathname !== "/") { + bunkerIdentifier = parsed.pathname.replace(/^\/+/, ""); + } + + const normalizedIdentifier = normalizeNostrPubkey(bunkerIdentifier); + + if (remoteSignerParam) { + userPubkeyHint = normalizedIdentifier; + } else if (!remotePubkey) { + remotePubkey = normalizedIdentifier; + } + + if (!userPubkeyHint) { + const userParamCandidates = [ + "user", + "user_pubkey", + "user-pubkey", + "pubkey", + "npub", + "profile", + ]; + for (const key of userParamCandidates) { + const candidate = decodeParam(params.get(key)); + if (candidate) { + userPubkeyHint = normalizeNostrPubkey(candidate); + if (userPubkeyHint) { + break; + } + } + } + + if (!userPubkeyHint && normalizedIdentifier && normalizedIdentifier !== remotePubkey) { + userPubkeyHint = normalizedIdentifier; + } } } else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") { clientPubkey = parsed.hostname || ""; if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") { clientPubkey = parsed.pathname.replace(/^\/+/, ""); } - remotePubkey = - params.get("remote") || - params.get("remotePubkey") || - params.get("signer") || - ""; } - const secretParam = params.get("secret") || ""; + const secretParam = decodeParam(params.get("secret")); return { scheme, @@ -3619,9 +3677,10 @@ function parseNip46ConnectionString(uri) { remotePubkey: normalizeNostrPubkey(remotePubkey), clientPubkey: normalizeNostrPubkey(clientPubkey), relays, - secret: typeof secretParam === "string" ? secretParam.trim() : "", + secret: secretParam, permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "", metadata, + userPubkeyHint: normalizeNostrPubkey(userPubkeyHint), }; } @@ -4063,6 +4122,39 @@ async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) throw failure; } +async function attemptDecryptNip46HandshakePayload({ + clientPrivateKey, + candidateRemotePubkeys = [], + ciphertext, +}) { + const tried = []; + const seen = new Set(); + + for (const candidate of candidateRemotePubkeys) { + const normalized = normalizeNostrPubkey(candidate); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + try { + const result = await decryptNip46PayloadWithKeys( + clientPrivateKey, + normalized, + ciphertext, + ); + return { ...result, remotePubkey: normalized }; + } catch (error) { + tried.push({ remotePubkey: normalized, error }); + } + } + + const failure = new Error( + "Failed to decrypt remote signer handshake payload with provided keys.", + ); + failure.attempts = tried; + throw failure; +} + class Nip46RpcClient { constructor({ nostrClient, @@ -5336,6 +5428,7 @@ export class NostrClient { onAuthUrl, onStatus, timeoutMs, + expectedRemotePubkey, } = {}) { const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey); if (!normalizedClientPublicKey) { @@ -5446,14 +5539,22 @@ export class NostrClient { return; } - const remotePubkey = normalizeNostrPubkey(event.pubkey); - if (!remotePubkey) { - return; + const eventRemotePubkey = normalizeNostrPubkey(event.pubkey); + const candidateRemotePubkeys = []; + if (expectedRemotePubkey) { + candidateRemotePubkeys.push(expectedRemotePubkey); + } + if (eventRemotePubkey) { + candidateRemotePubkeys.push(eventRemotePubkey); } Promise.resolve() .then(() => - decryptNip46PayloadWithKeys(clientPrivateKey, remotePubkey, event.content), + attemptDecryptNip46HandshakePayload({ + clientPrivateKey, + candidateRemotePubkeys, + ciphertext: event.content, + }), ) .then((payloadResult) => { const plaintext = payloadResult?.plaintext ?? ""; @@ -5504,7 +5605,7 @@ export class NostrClient { phase: "handshake", state: "acknowledged", message: "Remote signer acknowledged the connect request.", - remotePubkey, + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", }); } catch (callbackError) { devLogger.warn("[nostr] Handshake status callback threw:", callbackError); @@ -5512,7 +5613,8 @@ export class NostrClient { } resolve({ - remotePubkey, + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + eventPubkey: eventRemotePubkey || "", response: parsed, algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), }); @@ -5645,6 +5747,8 @@ export class NostrClient { state: "connecting", relays, metadata, + remotePubkey: parsed.remotePubkey || "", + userPubkey: parsed.userPubkeyHint || "", message: "Waiting for the signer to acknowledge the connection.", }); @@ -5658,6 +5762,7 @@ export class NostrClient { onAuthUrl: (url, context) => handleAuthChallenge(url, context), onStatus: handleStatus, timeoutMs: handshakeTimeoutMs, + expectedRemotePubkey: parsed.remotePubkey, }); } catch (error) { this.emitRemoteSignerChange({ @@ -5692,6 +5797,7 @@ export class NostrClient { remotePubkey, relays, metadata, + userPubkey: parsed.userPubkeyHint || "", }); } diff --git a/tests/nostr.nip46.test.mjs b/tests/nostr.nip46.test.mjs index 4a71ed65..44e398c3 100644 --- a/tests/nostr.nip46.test.mjs +++ b/tests/nostr.nip46.test.mjs @@ -288,3 +288,86 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy "nip04 decrypt should succeed for structured payload candidates", ); }); + +test("parseNip46ConnectionString handles remote signer key hints", async () => { + const nostrTools = await loadNostrTools(); + const { generateSecretKey, getPublicKey, nip19, utils } = nostrTools; + + const signerSecret = utils.bytesToHex(generateSecretKey()); + const userSecret = utils.bytesToHex(generateSecretKey()); + + const signerPubkey = getPublicKey(signerSecret).toLowerCase(); + const userPubkey = getPublicKey(userSecret).toLowerCase(); + + const signerNpub = nip19.npubEncode(signerPubkey); + const userNpub = nip19.npubEncode(userPubkey); + + const uri = + `bunker://${userNpub}?remote-signer-key=${encodeURIComponent(signerNpub)}` + + `&relay=${encodeURIComponent("wss://relay.example.com")}`; + + const { __testExports } = await import("../js/nostr.js"); + const { parseNip46ConnectionString } = __testExports; + + const parsed = parseNip46ConnectionString(uri); + + assert.ok(parsed, "parser should return a payload"); + assert.equal( + parsed.remotePubkey, + signerPubkey, + "remote signer pubkey should come from the remote-signer-key parameter", + ); + assert.equal( + parsed.userPubkeyHint, + userPubkey, + "user pubkey hint should reflect the bunker URI hostname", + ); + assert.deepEqual( + parsed.relays, + ["wss://relay.example.com"], + "relay parameters should be decoded", + ); +}); + +test( + "attemptDecryptNip46HandshakePayload falls back to expected remote signer key", + async () => { + const nostrTools = await loadNostrTools(); + const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + + const clientSecret = utils.bytesToHex(generateSecretKey()); + const remoteSignerSecret = utils.bytesToHex(generateSecretKey()); + const userSecret = utils.bytesToHex(generateSecretKey()); + + const remoteSignerPubkey = getPublicKey(remoteSignerSecret).toLowerCase(); + const userPubkey = getPublicKey(userSecret).toLowerCase(); + + const conversationKey = + typeof nip44.v2.getConversationKey === "function" + ? nip44.v2.getConversationKey(clientSecret, remoteSignerPubkey) + : nip44.v2.utils.getConversationKey(clientSecret, remoteSignerPubkey); + + const payload = JSON.stringify({ id: "ack", result: "ok" }); + const ciphertext = nip44.v2.encrypt(payload, conversationKey); + + const { __testExports } = await import("../js/nostr.js"); + const { attemptDecryptNip46HandshakePayload } = __testExports; + + const result = await attemptDecryptNip46HandshakePayload({ + clientPrivateKey: clientSecret, + candidateRemotePubkeys: [userPubkey, remoteSignerPubkey], + ciphertext, + }); + + assert.equal( + result.remotePubkey, + remoteSignerPubkey, + "helper should return the remote signer key that successfully decrypted the payload", + ); + assert.equal( + result.plaintext, + payload, + "helper should yield the handshake plaintext", + ); + }, +);