diff --git a/js/nostr/client.js b/js/nostr/client.js index e0f04168..7e9d1f2b 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2694,6 +2694,10 @@ export class NostrClient { return this.signerManager.installNip46Client(client, options); } + async prepareRemoteSignerHandshake(params) { + return this.signerManager.prepareRemoteSignerHandshake(params); + } + async connectRemoteSigner(params) { return this.signerManager.connectRemoteSigner(params); } diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index d92b6231..1ec195d5 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -643,6 +643,85 @@ export class SignerManager { }); } + /** + * Prepares a NIP-46 `nostrconnect://` handshake: generates an ephemeral client + * keypair and builds the connection URI the user shares with their remote + * signer (link/QR). The returned handshake is then handed to + * connectRemoteSigner(). This is the "generate connect link" login path; it was + * previously only implemented on the unused Nip46Connector, so the client's + * `prepareRemoteSignerHandshake` proxy had nothing to call. + * + * @param {object} [params] + * @param {object} [params.metadata] + * @param {string[]} [params.relays] + * @param {string} [params.secret] + * @param {string} [params.permissions] + * @returns {Promise} handshake { connectionString, clientPrivateKey, clientPublicKey, relays, secret, permissions, metadata } + */ + async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) { + const tools = await ensureNostrTools(); + if ( + !tools || + typeof tools.generateSecretKey !== "function" || + typeof tools.getPublicKey !== "function" + ) { + throw new Error( + "Nostr tools are unavailable; cannot prepare a remote signer handshake.", + ); + } + + const secretKey = tools.generateSecretKey(); + const clientPrivateKey = Array.from(secretKey) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const clientPublicKey = tools.getPublicKey(secretKey); + + const sanitizedMetadata = sanitizeNip46Metadata(metadata); + const requestedPermissions = + typeof permissions === "string" && permissions.trim() + ? permissions.trim() + : ""; + const resolvedRelays = resolveNip46Relays(relays, this.client?.relays); + const handshakeSecret = + typeof secret === "string" && secret.trim() + ? secret.trim() + : generateNip46Secret(); + + const params = []; + for (const relay of resolvedRelays) { + params.push(`relay=${encodeURIComponent(relay)}`); + } + if (handshakeSecret) { + params.push(`secret=${encodeURIComponent(handshakeSecret)}`); + } + if (requestedPermissions) { + params.push(`perms=${encodeURIComponent(requestedPermissions)}`); + } + for (const [key, value] of Object.entries(sanitizedMetadata)) { + params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + const query = params.length ? `?${params.join("&")}` : ""; + const uri = `nostrconnect://${clientPublicKey}${query}`; + + devLogger.debug("[nostr] Prepared remote signer handshake", { + clientPublicKey: summarizeHexForLog(clientPublicKey), + relays: resolvedRelays, + permissions: requestedPermissions || null, + }); + + return { + type: "client", + connectionString: uri, + uri, + clientPrivateKey, + clientPublicKey, + relays: resolvedRelays, + secret: handshakeSecret, + permissions: requestedPermissions, + metadata: sanitizedMetadata, + }; + } + async connectRemoteSigner({ connectionString, remember = true, diff --git a/tests/nostr/nip46-prepare-handshake.test.mjs b/tests/nostr/nip46-prepare-handshake.test.mjs new file mode 100644 index 00000000..90c8263b --- /dev/null +++ b/tests/nostr/nip46-prepare-handshake.test.mjs @@ -0,0 +1,52 @@ +// Scenario (SCN-nip46-prepare-handshake): +// The "generate connect link" NIP-46 login path calls +// nostrClient.prepareRemoteSignerHandshake(...). That method existed only on +// the unused Nip46Connector, so login failed with +// "nostrClient.prepareRemoteSignerHandshake is not a function". It now lives on +// SignerManager (and is proxied by NostrClient). This asserts it produces a +// valid nostrconnect:// handshake (ephemeral keypair + URI carrying the +// relays/secret/permissions) ready to hand to connectRemoteSigner. + +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"); + +function makeManager() { + // Minimal client stub — only relays are read (as the relay fallback). + return new SignerManager({ relays: ["wss://fallback.example"] }); +} + +test("prepareRemoteSignerHandshake builds a valid nostrconnect URI + ephemeral keypair", async () => { + const mgr = makeManager(); + const hs = await mgr.prepareRemoteSignerHandshake({ + relays: ["wss://relay.one", "wss://relay.two"], + permissions: "sign_event:1,nip44_decrypt", + metadata: { name: "bitvid" }, + }); + + // Ephemeral keypair (hex), pubkey embedded in the URI. + assert.match(hs.clientPrivateKey, /^[0-9a-f]{64}$/); + assert.match(hs.clientPublicKey, /^[0-9a-f]{64}$/); + assert.match(hs.connectionString, /^nostrconnect:\/\/[0-9a-f]{64}\?/); + assert.ok(hs.connectionString.includes(hs.clientPublicKey), "URI carries the client pubkey"); + + // Carries the requested relays, a secret, and the permissions. + assert.ok(hs.connectionString.includes(`relay=${encodeURIComponent("wss://relay.one")}`)); + assert.ok(hs.connectionString.includes(`relay=${encodeURIComponent("wss://relay.two")}`)); + assert.ok(hs.secret && hs.connectionString.includes(`secret=${encodeURIComponent(hs.secret)}`)); + assert.ok( + hs.connectionString.includes(`perms=${encodeURIComponent("sign_event:1,nip44_decrypt")}`), + ); + assert.ok(hs.relays.includes("wss://relay.one") && hs.relays.includes("wss://relay.two")); +}); + +test("generates a fresh keypair each call", async () => { + const mgr = makeManager(); + const a = await mgr.prepareRemoteSignerHandshake({ relays: ["wss://r"] }); + const b = await mgr.prepareRemoteSignerHandshake({ relays: ["wss://r"] }); + assert.notEqual(a.clientPublicKey, b.clientPublicKey, "each handshake is a new ephemeral key"); + assert.notEqual(a.secret, b.secret); +});