diff --git a/js/nostr/client.js b/js/nostr/client.js index 373c058a..c1790c62 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -206,6 +206,7 @@ import { createNsecAdapter } from "./adapters/nsecAdapter.js"; import { createNip46Adapter } from "./adapters/nip46Adapter.js"; import { queueSignEvent } from "./signRequestQueue.js"; import { EventsMap } from "./eventsMap.js"; +import { Nip46Connector } from "./nip46Connector.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -1433,6 +1434,7 @@ export class NostrClient { this.pendingHandshakeCancel = null; this.remoteSignerListeners = new Set(); this.sessionActorListeners = new Set(); + this.nip46Connector = new Nip46Connector(this); const storedRemoteSigner = this.getStoredNip46Metadata(); this.remoteSignerStatus = { state: storedRemoteSigner.hasSession ? "stored" : "idle", @@ -1643,115 +1645,14 @@ export class NostrClient { } async createNip46KeyPair(existingPrivateKey = "", existingPublicKey = "") { - let privateKey = - typeof existingPrivateKey === "string" && existingPrivateKey.trim() - ? existingPrivateKey.trim().toLowerCase() - : ""; - - if (privateKey && !HEX64_REGEX.test(privateKey)) { - const error = new Error("Invalid remote signer private key."); - error.code = "invalid-private-key"; - throw error; - } - - if (!privateKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools) { - throw new Error("Unable to generate a remote signer key pair."); - } - - let generated = null; - if (typeof tools.generateSecretKey === "function") { - generated = tools.generateSecretKey(); - } - - if (generated instanceof Uint8Array) { - privateKey = bytesToHex(generated); - } else if (Array.isArray(generated)) { - privateKey = bytesToHex(Uint8Array.from(generated)); - } else if (typeof generated === "string") { - privateKey = generated.trim().toLowerCase(); - } - - if (!privateKey || !HEX64_REGEX.test(privateKey)) { - throw new Error("Generated remote signer key is invalid."); - } - - privateKey = privateKey.toLowerCase(); - } - - let publicKey = - typeof existingPublicKey === "string" && existingPublicKey.trim() - ? existingPublicKey.trim().toLowerCase() - : ""; - - if (!publicKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools || typeof tools.getPublicKey !== "function") { - throw new Error("Public key derivation is unavailable for remote signing."); - } - publicKey = tools.getPublicKey(privateKey); - } - - if (!publicKey || !HEX64_REGEX.test(publicKey)) { - throw new Error("Derived remote signer public key is invalid."); - } - - return { privateKey, publicKey: publicKey.toLowerCase() }; + return this.nip46Connector.createKeyPair( + existingPrivateKey, + existingPublicKey, + ); } - async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) { - const keyPair = await this.createNip46KeyPair(); - const sanitizedMetadata = sanitizeNip46Metadata(metadata); - const requestedPermissions = - typeof permissions === "string" && permissions.trim() ? permissions.trim() : ""; - - const resolvedRelays = resolveNip46Relays(relays, this.relays); - const handshakeSecret = - typeof secret === "string" && secret.trim() ? secret.trim() : generateNip46Secret(); - - devLogger.debug("[nostr] Preparing remote signer handshake", { - clientPublicKey: summarizeHexForLog(keyPair.publicKey), - relays: resolvedRelays, - permissions: requestedPermissions || null, - secret: summarizeSecretForLog(handshakeSecret), - metadataKeys: summarizeMetadataForLog(sanitizedMetadata), - }); - - 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://${keyPair.publicKey}${query}`; - - devLogger.debug("[nostr] Prepared nostrconnect URI", { - uri, - relayCount: resolvedRelays.length, - metadataKeys: summarizeMetadataForLog(sanitizedMetadata), - }); - - return { - type: "client", - connectionString: uri, - uri, - clientPrivateKey: keyPair.privateKey, - clientPublicKey: keyPair.publicKey, - relays: resolvedRelays, - secret: handshakeSecret, - permissions: requestedPermissions, - metadata: sanitizedMetadata, - }; + async prepareRemoteSignerHandshake(options = {}) { + return this.nip46Connector.prepareHandshake(options); } async installNip46Client(client, { userPubkey } = {}) { @@ -1774,296 +1675,6 @@ export class NostrClient { return signer; } - /** - * Waits for a remote signer (Nostr Connect) to acknowledge a connection request. - * - * **Protocol (NIP-46):** - * - Subscribes to Kind 24133 (NIP-46 RPC) events addressed to the client. - * - Decrypts incoming messages using the ephemeral `clientPrivateKey`. - * - Looks for "connect" acknowledgement or "auth_url" challenges. - * - * @param {object} params - * @param {string} params.clientPrivateKey - Ephemeral private key for the handshake. - * @param {string} params.clientPublicKey - Ephemeral public key. - * @param {string[]} params.relays - Relay list for the handshake. - * @param {string} [params.secret] - Optional secret to validate the "ack" response. - * @param {function} [params.onAuthUrl] - Callback for out-of-band auth challenges. - * @param {function} [params.onStatus] - Callback for status updates. - * @param {number} [params.timeoutMs] - Max wait time. - * @param {string} [params.expectedRemotePubkey] - If known, filters events to this sender. - * @returns {Promise<{remotePubkey: string, eventPubkey: string, response: object, algorithm: string}>} - */ - async waitForRemoteSignerHandshake({ - clientPrivateKey, - clientPublicKey, - relays, - secret, - onAuthUrl, - onStatus, - timeoutMs, - expectedRemotePubkey, - } = {}) { - 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.relays); - if (!resolvedRelays.length) { - throw new Error("No relays available to complete the remote signer handshake."); - } - - devLogger.debug("[nostr] Waiting for remote signer handshake", { - clientPubkey: summarizeHexForLog(normalizedClientPublicKey), - relays: resolvedRelays, - secret: summarizeSecretForLog(secret), - timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, - expectedRemotePubkey: summarizeHexForLog(expectedRemotePubkey || ""), - }); - - const pool = await this.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") { - const preferredKeys = [ - "secret", - "message", - "status", - "reason", - "detail", - "description", - "value", - "result", - "url", - ]; - for (const key of preferredKeys) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - const candidate = coerceStructuredString(value[key]); - if (candidate) { - return candidate; - } - } - } - for (const entry of Object.values(value)) { - const candidate = coerceStructuredString(entry); - if (candidate) { - return candidate; - } - } - } - return ""; - }; - - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - if (settled) { - return; - } - settled = true; - if (this.pendingHandshakeCancel === cancelHandshake) { - this.pendingHandshakeCancel = null; - } - try { - subscription?.unsub?.(); - } catch (error) { - // ignore subscription cleanup failures - } - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - - const cancelHandshake = () => { - cleanup(); - const error = new Error("Remote signer connection cancelled."); - error.code = "login-cancelled"; - reject(error); - }; - - this.pendingHandshakeCancel = cancelHandshake; - - const timeoutId = setTimeout(() => { - cleanup(); - const error = new Error("Timed out waiting for the remote signer to acknowledge the connection."); - error.code = "nip46-handshake-timeout"; - devLogger.warn("[nostr] Handshake wait timed out", { - clientPubkey: summarizeHexForLog(normalizedClientPublicKey), - relays: resolvedRelays, - }); - reject(error); - }, waitTimeout); - - let subscription; - try { - subscription = pool.sub(resolvedRelays, filters); - devLogger.debug("[nostr] Handshake subscription established", { - relays: resolvedRelays, - filters, - }); - } catch (error) { - cleanup(); - devLogger.warn("[nostr] Failed to subscribe for handshake responses", error); - reject(error); - return; - } - - subscription.on("event", (event) => { - if (settled) { - return; - } - - if (!event || event.kind !== NIP46_RPC_KIND) { - devLogger.debug("[nostr] Ignoring non-handshake event during wait", event); - return; - } - - const eventRemotePubkey = normalizeNostrPubkey(event.pubkey); - const candidateRemotePubkeys = []; - if (expectedRemotePubkey) { - candidateRemotePubkeys.push(expectedRemotePubkey); - } - if (eventRemotePubkey) { - candidateRemotePubkeys.push(eventRemotePubkey); - } - - devLogger.debug("[nostr] Processing handshake event", { - eventId: typeof event.id === "string" ? event.id : "", - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - candidateRemotePubkeys: candidateRemotePubkeys.map((key) => - summarizeHexForLog(key), - ), - contentLength: typeof event.content === "string" ? event.content.length : 0, - }); - - Promise.resolve() - .then(() => - attemptDecryptNip46HandshakePayload({ - clientPrivateKey, - candidateRemotePubkeys, - ciphertext: event.content, - }), - ) - .then((payloadResult) => { - const plaintext = payloadResult?.plaintext ?? ""; - let parsed; - try { - parsed = JSON.parse(plaintext); - } catch (error) { - devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error); - return; - } - - devLogger.debug("[nostr] Handshake payload parsed", { - remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || ""), - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - algorithm: payloadResult?.algorithm || null, - requestId: typeof parsed?.id === "string" ? parsed.id : "", - hasResult: parsed?.result !== undefined, - hasError: parsed?.error !== undefined, - }); - - const resultValue = coerceStructuredString(parsed?.result); - const errorValue = coerceStructuredString(parsed?.error); - - if (resultValue === "auth_url" && errorValue) { - const handshakeRemotePubkey = payloadResult?.remotePubkey || eventRemotePubkey || ""; - devLogger.debug("[nostr] Handshake provided auth_url challenge", { - eventId: typeof parsed?.id === "string" ? parsed.id : "", - remotePubkey: summarizeHexForLog(handshakeRemotePubkey), - url: summarizeUrlForLog(errorValue), - }); - if (typeof onAuthUrl === "function") { - try { - onAuthUrl(errorValue, { - phase: "handshake", - remotePubkey: handshakeRemotePubkey, - requestId: typeof parsed?.id === "string" ? parsed.id : "", - }); - } catch (callbackError) { - devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError); - } - } - return; - } - - if (secret) { - const normalizedResult = resultValue ? resultValue.toLowerCase() : ""; - if (resultValue !== secret && normalizedResult !== "ack") { - return; - } - } - - if (!secret && resultValue) { - const normalized = resultValue.toLowerCase(); - if (!["ack", "ok", "success"].includes(normalized)) { - 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), - }); - - devLogger.debug("[nostr] Handshake wait resolved", { - remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || eventRemotePubkey || ""), - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - result: resultValue || null, - hasSecret: Boolean(secret), - }); - }) - .catch((error) => { - devLogger.warn("[nostr] Failed to decrypt remote signer handshake payload:", error); - }); - }); - - subscription.on("eose", () => { - // no-op: handshake responses are push-based - }); - }); - } /** * Establishes a NIP-46 connection with a remote signer (e.g., Nostr Connect / Bunker). @@ -2230,602 +1841,16 @@ export class NostrClient { return { pubkey, signer: adapter }; } - async connectRemoteSigner({ - connectionString, - remember = true, - clientPrivateKey: providedClientPrivateKey = "", - clientPublicKey: providedClientPublicKey = "", - relays: providedRelays = [], - secret: providedSecret = "", - permissions: providedPermissions = "", - metadata: providedMetadata = {}, - onAuthUrl, - onStatus, - handshakeTimeoutMs, - passphrase, - } = {}) { - const parsed = parseNip46ConnectionString(connectionString); - if (!parsed) { - const error = new Error( - "Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.", - ); - error.code = "invalid-connection-string"; - throw error; - } - - const baseMetadata = sanitizeNip46Metadata(parsed.metadata); - const overrideMetadata = sanitizeNip46Metadata(providedMetadata); - const metadata = { ...baseMetadata, ...overrideMetadata }; - - devLogger.debug("[nostr] Connecting to remote signer", { - connectionType: parsed.type, - parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""), - providedClientPublicKey: summarizeHexForLog(providedClientPublicKey), - providedClientPrivateKey: summarizeSecretForLog( - typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "", - ), - providedSecret: summarizeSecretForLog(providedSecret), - providedPermissions: providedPermissions || null, - handshakeTimeoutMs, - parsedRelays: parsed.relays, - overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0, - metadataKeys: summarizeMetadataForLog(metadata), - }); - - const handleStatus = (status) => { - if (typeof onStatus !== "function") { - return; - } - try { - onStatus(status); - } catch (error) { - devLogger.warn("[nostr] Remote signer status callback threw:", error); - } - }; - - const handleAuthChallenge = async (url, context = {}) => { - if (typeof onAuthUrl !== "function" || !url) { - return; - } - try { - devLogger.debug("[nostr] Remote signer auth challenge surfaced", { - url: summarizeUrlForLog(url), - context, - }); - const result = onAuthUrl(url, context); - if (result && typeof result.then === "function") { - await result.catch((error) => { - devLogger.warn("[nostr] Auth challenge callback promise rejected:", error); - }); - } - } catch (error) { - devLogger.warn("[nostr] Remote signer auth callback threw:", error); - } - }; - - const mergedRelaysSource = parsed.relays.length ? parsed.relays : providedRelays; - const relays = resolveNip46Relays(mergedRelaysSource, this.relays); - - let secret = typeof providedSecret === "string" && providedSecret.trim() ? providedSecret.trim() : parsed.secret; - let permissions = - typeof providedPermissions === "string" && providedPermissions.trim() - ? providedPermissions.trim() - : parsed.permissions; - - let clientPrivateKey = ""; - let clientPublicKey = ""; - let remotePubkey = normalizeNostrPubkey(parsed.remotePubkey); - let handshakeAlgorithm = ""; - - if (parsed.type === "client") { - clientPrivateKey = - typeof providedClientPrivateKey === "string" && providedClientPrivateKey.trim() - ? providedClientPrivateKey.trim().toLowerCase() - : ""; - - if (!clientPrivateKey || !HEX64_REGEX.test(clientPrivateKey)) { - const error = new Error( - "Remote signer handshake requires the generated client private key.", - ); - error.code = "missing-client-private-key"; - throw error; - } - - clientPublicKey = normalizeNostrPubkey(providedClientPublicKey) || parsed.clientPubkey; - if (!clientPublicKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools || typeof tools.getPublicKey !== "function") { - throw new Error("Public key derivation is unavailable for the remote signer handshake."); - } - clientPublicKey = normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey)); - } - - if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) { - const error = new Error("Invalid client public key for the remote signer handshake."); - error.code = "invalid-client-public-key"; - throw error; - } - - if (parsed.clientPubkey && normalizeNostrPubkey(parsed.clientPubkey) !== clientPublicKey) { - const error = new Error("Handshake public key mismatch detected."); - error.code = "client-public-key-mismatch"; - throw error; - } - - if (!secret) { - secret = generateNip46Secret(); - } - - handleStatus({ - phase: "handshake", - state: "waiting", - message: "Waiting for the signer to acknowledge the connection…", - relays, - }); - - this.emitRemoteSignerChange({ - state: "connecting", - relays, - metadata, - remotePubkey: parsed.remotePubkey || "", - userPubkey: parsed.userPubkeyHint || "", - message: "Waiting for the signer to acknowledge the connection.", - }); - - let handshakeResult; - try { - handshakeResult = await this.waitForRemoteSignerHandshake({ - clientPrivateKey, - clientPublicKey, - relays, - secret, - onAuthUrl: (url, context) => handleAuthChallenge(url, context), - onStatus: handleStatus, - timeoutMs: handshakeTimeoutMs, - expectedRemotePubkey: parsed.remotePubkey, - }); - devLogger.debug("[nostr] Remote signer handshake completed", { - remotePubkey: summarizeHexForLog(handshakeResult?.remotePubkey || parsed.remotePubkey || ""), - algorithm: handshakeResult?.algorithm || null, - relays, - secret: summarizeSecretForLog(secret), - }); - } catch (error) { - this.emitRemoteSignerChange({ - state: "error", - relays, - metadata, - message: error?.message || "Remote signer handshake failed.", - error, - }); - throw error; - } - - remotePubkey = normalizeNostrPubkey(handshakeResult?.remotePubkey); - handshakeAlgorithm = normalizeNip46EncryptionAlgorithm( - handshakeResult?.algorithm, - ); - if (!remotePubkey) { - const error = new Error("Remote signer did not return a valid public key."); - error.code = "missing-remote-pubkey"; - throw error; - } - devLogger.debug("[nostr] Remote signer handshake provided final pubkey", { - remotePubkey: summarizeHexForLog(remotePubkey), - algorithm: handshakeAlgorithm || null, - clientPublicKey: summarizeHexForLog(clientPublicKey), - }); - } else { - const keyPair = await this.createNip46KeyPair( - providedClientPrivateKey, - providedClientPublicKey, - ); - clientPrivateKey = keyPair.privateKey; - clientPublicKey = keyPair.publicKey; - - this.emitRemoteSignerChange({ - state: "connecting", - remotePubkey, - relays, - metadata, - userPubkey: parsed.userPubkeyHint || "", - }); - devLogger.debug("[nostr] Using bunker URI for remote signer connect", { - remotePubkey: summarizeHexForLog(remotePubkey || parsed.remotePubkey || ""), - relays, - metadataKeys: summarizeMetadataForLog(metadata), - }); - } - - if (!remotePubkey) { - remotePubkey = normalizeNostrPubkey(parsed.remotePubkey); - } - - if (!remotePubkey) { - const error = new Error("Remote signer pubkey is required to establish the session."); - error.code = "missing-remote-pubkey"; - throw error; - } - - const client = new Nip46RpcClient({ - nostrClient: this, - clientPrivateKey, - clientPublicKey, - remotePubkey, - relays, - secret, - permissions, - metadata, - encryption: handshakeAlgorithm, - signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), - }); - - devLogger.debug("[nostr] Remote signer RPC client created", { - clientPublicKey: summarizeHexForLog(client.clientPublicKey), - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - permissions, - secret: summarizeSecretForLog(secret), - metadataKeys: summarizeMetadataForLog(metadata), - handshakeAlgorithm, - }); - - try { - await client.ensureSubscription(); - - devLogger.debug("[nostr] Remote signer subscription ready", { - remotePubkey: summarizeHexForLog(remotePubkey), - relayCount: relays.length, - }); - - handleStatus({ - phase: "connect", - state: "request", - message: "Requesting approval from the remote signer…", - remotePubkey, - }); - - let attempts = 0; - // Attempt the connect RPC, handling auth challenges when provided. - for (;;) { - try { - devLogger.debug("[nostr] Sending NIP-46 connect request", { - remotePubkey: summarizeHexForLog(remotePubkey), - attempt: attempts + 1, - permissions: permissions || null, - }); - await client.connect({ permissions }); - break; - } catch (error) { - devLogger.warn("[nostr] Connect RPC attempt failed", { - remotePubkey: summarizeHexForLog(remotePubkey), - attempt: attempts + 1, - code: error?.code || null, - message: error?.message || String(error), - }); - if (error?.code === "auth-challenge" && error.authUrl) { - attempts += 1; - handleStatus({ - phase: "auth", - state: "waiting", - message: "Complete the authentication challenge in your signer…", - remotePubkey, - attempt: attempts, - }); - await handleAuthChallenge(error.authUrl, { - phase: "connect", - remotePubkey, - attempt: attempts, - }); - - if (attempts >= NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS) { - throw error; - } - continue; - } - throw error; - } - } - - const userPubkey = await client.getUserPubkey(); - devLogger.debug("[nostr] Retrieved user pubkey from remote signer", { - remotePubkey: summarizeHexForLog(remotePubkey), - userPubkey: summarizeHexForLog(userPubkey), - }); - - if (typeof options?.validator === "function") { - try { - const isValid = await options.validator(userPubkey); - if (!isValid) { - throw new Error("Access denied."); - } - } catch (validationError) { - const message = - validationError instanceof Error - ? validationError.message - : "Access denied."; - const error = new Error(message); - error.code = "access-denied"; - client.destroy(); // Cleanup client since we won't use it - throw error; - } - } - - client.metadata = metadata; - const signer = await this.installNip46Client(client, { userPubkey }); - - if (remember) { - devLogger.debug("[nostr] Persisting remote signer session", { - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions: permissions || null, - }); - await writeStoredNip46Session( - { - version: 1, - clientPublicKey, - remotePubkey, - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions, - metadata, - userPubkey, - lastConnectedAt: Date.now(), - clientPrivateKey, - secret, - }, - passphrase, - ); - } else { - devLogger.debug( - "[nostr] Clearing stored remote signer session per request", - ); - clearStoredNip46Session(); - } - - this.emitRemoteSignerChange({ - state: "connected", - remotePubkey, - userPubkey, - relays, - metadata, - }); - - handleStatus({ - phase: "connected", - state: "ready", - message: "Remote signer connected successfully.", - remotePubkey, - userPubkey, - }); - - devLogger.debug("[nostr] Remote signer connection established", { - remotePubkey: summarizeHexForLog(remotePubkey), - userPubkey: summarizeHexForLog(userPubkey), - relays, - permissions: permissions || null, - secret: summarizeSecretForLog(secret), - }); - - return { pubkey: userPubkey, signer }; - } catch (error) { - devLogger.error("[nostr] Remote signer connection failed", { - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - permissions: permissions || null, - secret: summarizeSecretForLog(secret), - message: error?.message || String(error), - code: error?.code || null, - }); - client.destroy(); - this.nip46Client = null; - if (!remember) { - clearStoredNip46Session(); - } - this.emitRemoteSignerChange({ - state: "error", - remotePubkey, - relays, - metadata, - message: error?.message || "Remote signer connection failed.", - error, - }); - throw error; - } + async connectRemoteSigner(options = {}) { + const { client, pubkey } = await this.nip46Connector.connect(options); + const signer = await this.installNip46Client(client, { userPubkey: pubkey }); + return { pubkey, signer }; } - /** - * Reconnects to a previously persisted NIP-46 remote signer session. - * - * **Usage:** - * Called during `init()` to restore the session without user interaction. - * It reads credentials from localStorage (`bitvid:nip46:session`), validates them, - * and re-establishes the RPC subscription. - * - * @param {object} [options] - * @param {boolean} [options.silent=false] - Suppress error logs if restore fails. - * @param {boolean} [options.forgetOnError=false] - Auto-delete session if invalid. - * @returns {Promise<{pubkey: string, signer: object}>} - */ async useStoredRemoteSigner(options = {}) { - const normalizedOptions = - options && typeof options === "object" ? options : {}; - const silent = normalizedOptions.silent === true; - const forgetOnError = normalizedOptions.forgetOnError === true; - const passphrase = - typeof normalizedOptions.passphrase === "string" - ? normalizedOptions.passphrase - : null; - const validator = - typeof normalizedOptions.validator === "function" - ? normalizedOptions.validator - : null; - - let stored = readStoredNip46Session(); - if (!stored) { - const error = new Error( - "No remote signer session is stored on this device.", - ); - error.code = "no-stored-session"; - throw error; - } - - if (stored.encryptedSecrets) { - if (!passphrase) { - const error = new Error("Passphrase required to unlock session."); - error.code = "passphrase-required"; - throw error; - } - try { - stored = await decryptNip46Session(stored, passphrase); - } catch (decryptError) { - const error = new Error("Failed to decrypt stored session."); - error.code = "decrypt-failed"; - error.cause = decryptError; - throw error; - } - } - - const relays = resolveNip46Relays(stored.relays, this.relays); - - devLogger.debug("[nostr] Reconnecting to stored remote signer", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - relays, - encryption: stored.encryption || "", - permissions: stored.permissions || null, - hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), - }); - - this.emitRemoteSignerChange({ - state: "connecting", - remotePubkey: stored.remotePubkey, - relays, - metadata: stored.metadata, - }); - - if (!stored.clientPrivateKey || !stored.secret) { - const error = new Error( - "Stored remote signer credentials are unavailable.", - ); - error.code = "stored-session-missing-credentials"; - throw error; - } - - const client = new Nip46RpcClient({ - nostrClient: this, - clientPrivateKey: stored.clientPrivateKey, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays, - encryption: stored.encryption, - secret: stored.secret, - permissions: stored.permissions, - metadata: stored.metadata, - signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), - }); - - try { - await client.ensureSubscription(); - await client.connect({ permissions: stored.permissions }); - const userPubkey = await client.getUserPubkey(); - - if (validator) { - try { - const isValid = await validator(userPubkey); - if (!isValid) { - throw new Error("Access denied."); - } - } catch (validationError) { - const message = - validationError instanceof Error - ? validationError.message - : "Access denied."; - const error = new Error(message); - error.code = "access-denied"; - client.destroy(); - throw error; - } - } - - client.metadata = stored.metadata; - const signer = await this.installNip46Client(client, { userPubkey }); - - await writeStoredNip46Session( - { - version: 1, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays: stored.relays, - encryption: client.encryptionAlgorithm || stored.encryption || "", - permissions: stored.permissions, - metadata: stored.metadata, - userPubkey, - lastConnectedAt: Date.now(), - clientPrivateKey: stored.clientPrivateKey, - secret: stored.secret, - }, - passphrase, - ); - - devLogger.debug("[nostr] Stored remote signer session refreshed", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - userPubkey: summarizeHexForLog(userPubkey), - encryption: client.encryptionAlgorithm || stored.encryption || "", - }); - - this.emitRemoteSignerChange({ - state: "connected", - remotePubkey: stored.remotePubkey, - userPubkey, - relays, - metadata: stored.metadata, - }); - - return { pubkey: userPubkey, signer }; - } catch (error) { - await client.destroy().catch(() => {}); - this.nip46Client = null; - const fatalCodes = new Set([ - "nip46-secret-mismatch", - "invalid-private-key", - "invalid-connection-string", - ]); - const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code); - if (shouldForgetStored) { - clearStoredNip46Session(); - devLogger.warn("[nostr] Stored remote signer session cleared after failure", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - error: error?.message || String(error), - code: error?.code || null, - }); - } - - const status = { - state: shouldForgetStored ? "idle" : silent ? "stored" : "error", - remotePubkey: stored.remotePubkey, - relays, - metadata: stored.metadata, - }; - - if (!silent || shouldForgetStored) { - status.message = - error?.message || "Failed to reconnect to the remote signer."; - } - status.error = error; - - this.emitRemoteSignerChange(status); - - if (silent) { - devLogger.log("[nostr] Silent remote signer restore failed:", error); - } else { - devLogger.warn( - "[nostr] Stored remote signer reconnection failed:", - error, - ); - } - throw error; - } + const { client, pubkey } = await this.nip46Connector.reconnectStored(options); + const signer = await this.installNip46Client(client, { userPubkey: pubkey }); + return { pubkey, signer }; } scheduleStoredRemoteSignerRestore() { @@ -2852,23 +1877,9 @@ export class NostrClient { return attempt; } - /** - * Terminates the NIP-46 remote signer connection. - * - * @param {object} [options] - * @param {boolean} [options.keepStored=true] - If false, wipes the session from localStorage. - */ async disconnectRemoteSigner({ keepStored = true } = {}) { this.pendingRemoteSignerRestore = null; - - if (this.pendingHandshakeCancel) { - try { - this.pendingHandshakeCancel(); - } catch (error) { - devLogger.warn("[nostr] Failed to cancel pending handshake:", error); - } - this.pendingHandshakeCancel = null; - } + await this.nip46Connector.disconnect({ keepStored }); if (this.nip46Client) { try { @@ -2884,10 +1895,6 @@ export class NostrClient { logoutSigner(activeSigner.pubkey); } - if (!keepStored) { - clearStoredNip46Session(); - } - const stored = keepStored ? this.getStoredNip46Metadata() : { hasSession: false }; this.emitRemoteSignerChange({ state: stored.hasSession ? "stored" : "idle", diff --git a/js/nostr/nip46Connector.js b/js/nostr/nip46Connector.js new file mode 100644 index 00000000..158a572a --- /dev/null +++ b/js/nostr/nip46Connector.js @@ -0,0 +1,1093 @@ +// js/nostr/nip46Connector.js + +import { isDevMode } from "../config.js"; +import { HEX64_REGEX } from "../utils/hex.js"; +import { devLogger, userLogger } from "../utils/logger.js"; +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; +import { signEventWithPrivateKey } from "./publishHelpers.js"; +import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js"; +import { + normalizeNostrPubkey, + encodeHexToNpub, + generateNip46Secret, + sanitizeNip46Metadata, + resolveNip46Relays, + normalizeNip46EncryptionAlgorithm, + parseNip46ConnectionString, + attemptDecryptNip46HandshakePayload, + readStoredNip46Session, + writeStoredNip46Session, + clearStoredNip46Session, + decryptNip46Session, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS, + Nip46RpcClient, +} from "./nip46Client.js"; +import { + summarizeHexForLog, + summarizeSecretForLog, + summarizeMetadataForLog, + summarizeUrlForLog, +} from "./nip46LoggingUtils.js"; + +const DEFAULT_DEPS = { + ensureNostrTools, + getCachedNostrTools, + signEventWithPrivateKey, + bytesToHex, + normalizeNostrPubkey, + encodeHexToNpub, + generateNip46Secret, + sanitizeNip46Metadata, + resolveNip46Relays, + normalizeNip46EncryptionAlgorithm, + parseNip46ConnectionString, + attemptDecryptNip46HandshakePayload, + readStoredNip46Session, + writeStoredNip46Session, + clearStoredNip46Session, + decryptNip46Session, + Nip46RpcClient, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS, +}; + +/** + * Manages the connection lifecycle for NIP-46 Remote Signers. + * Encapsulates handshake, connection establishment, and reconnection logic. + */ +export class Nip46Connector { + /** + * @param {import("./client.js").NostrClient} nostrClient + * @param {object} [deps] - Optional dependencies for testing + */ + constructor(nostrClient, deps = {}) { + this.nostrClient = nostrClient; + this.deps = { ...DEFAULT_DEPS, ...deps }; + this.pendingHandshakeCancel = null; + this.pendingRemoteSignerRestore = null; + } + + /** + * Creates a new key pair for the NIP-46 client session. + * If existing keys are provided, they are validated and returned. + * + * @param {string} [existingPrivateKey] + * @param {string} [existingPublicKey] + * @returns {Promise<{privateKey: string, publicKey: string}>} + */ + async createKeyPair(existingPrivateKey = "", existingPublicKey = "") { + let privateKey = + typeof existingPrivateKey === "string" && existingPrivateKey.trim() + ? existingPrivateKey.trim().toLowerCase() + : ""; + + if (privateKey && !HEX64_REGEX.test(privateKey)) { + const error = new Error("Invalid remote signer private key."); + error.code = "invalid-private-key"; + throw error; + } + + if (!privateKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools) { + throw new Error("Unable to generate a remote signer key pair."); + } + + let generated = null; + if (typeof tools.generateSecretKey === "function") { + generated = tools.generateSecretKey(); + } + + if (generated instanceof Uint8Array) { + privateKey = this.deps.bytesToHex(generated); + } else if (Array.isArray(generated)) { + privateKey = this.deps.bytesToHex(Uint8Array.from(generated)); + } else if (typeof generated === "string") { + privateKey = generated.trim().toLowerCase(); + } + + if (!privateKey || !HEX64_REGEX.test(privateKey)) { + throw new Error("Generated remote signer key is invalid."); + } + + privateKey = privateKey.toLowerCase(); + } + + let publicKey = + typeof existingPublicKey === "string" && existingPublicKey.trim() + ? existingPublicKey.trim().toLowerCase() + : ""; + + if (!publicKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable for remote signing."); + } + publicKey = tools.getPublicKey(privateKey); + } + + if (!publicKey || !HEX64_REGEX.test(publicKey)) { + throw new Error("Derived remote signer public key is invalid."); + } + + return { privateKey, publicKey: publicKey.toLowerCase() }; + } + + /** + * Prepares the parameters for a NIP-46 handshake (generation of connection URI). + * + * @param {object} params + * @param {object} [params.metadata] + * @param {string[]} [params.relays] + * @param {string} [params.secret] + * @param {string} [params.permissions] + * @returns {Promise} + */ + async prepareHandshake({ metadata, relays, secret, permissions } = {}) { + const keyPair = await this.createKeyPair(); + const sanitizedMetadata = this.deps.sanitizeNip46Metadata(metadata); + const requestedPermissions = + typeof permissions === "string" && permissions.trim() ? permissions.trim() : ""; + + const resolvedRelays = this.deps.resolveNip46Relays(relays, this.nostrClient.relays); + const handshakeSecret = + typeof secret === "string" && secret.trim() ? secret.trim() : this.deps.generateNip46Secret(); + + devLogger.debug("[nostr] Preparing remote signer handshake", { + clientPublicKey: summarizeHexForLog(keyPair.publicKey), + relays: resolvedRelays, + permissions: requestedPermissions || null, + secret: summarizeSecretForLog(handshakeSecret), + metadataKeys: summarizeMetadataForLog(sanitizedMetadata), + }); + + 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://${keyPair.publicKey}${query}`; + + devLogger.debug("[nostr] Prepared nostrconnect URI", { + uri, + relayCount: resolvedRelays.length, + metadataKeys: summarizeMetadataForLog(sanitizedMetadata), + }); + + return { + type: "client", + connectionString: uri, + uri, + clientPrivateKey: keyPair.privateKey, + clientPublicKey: keyPair.publicKey, + relays: resolvedRelays, + secret: handshakeSecret, + permissions: requestedPermissions, + metadata: sanitizedMetadata, + }; + } + + /** + * Waits for a remote signer to acknowledge the handshake. + * + * @param {object} params + * @returns {Promise<{remotePubkey: string, eventPubkey: string, response: object, algorithm: string}>} + */ + async waitForHandshake({ + clientPrivateKey, + clientPublicKey, + relays, + secret, + onAuthUrl, + onStatus, + timeoutMs, + expectedRemotePubkey, + } = {}) { + const normalizedClientPublicKey = this.deps.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 = this.deps.resolveNip46Relays(relays, this.nostrClient.relays); + if (!resolvedRelays.length) { + throw new Error("No relays available to complete the remote signer handshake."); + } + + devLogger.debug("[nostr] Waiting for remote signer handshake", { + clientPubkey: summarizeHexForLog(normalizedClientPublicKey), + relays: resolvedRelays, + secret: summarizeSecretForLog(secret), + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + expectedRemotePubkey: summarizeHexForLog(expectedRemotePubkey || ""), + }); + + const pool = await this.nostrClient.ensurePool(); + const filters = [ + { + kinds: [this.deps.NIP46_RPC_KIND], + "#p": [normalizedClientPublicKey], + }, + ]; + + const waitTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : this.deps.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") { + const preferredKeys = [ + "secret", + "message", + "status", + "reason", + "detail", + "description", + "value", + "result", + "url", + ]; + for (const key of preferredKeys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const candidate = coerceStructuredString(value[key]); + if (candidate) { + return candidate; + } + } + } + for (const entry of Object.values(value)) { + const candidate = coerceStructuredString(entry); + if (candidate) { + return candidate; + } + } + } + return ""; + }; + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + if (settled) { + return; + } + settled = true; + if (this.pendingHandshakeCancel === cancelHandshake) { + this.pendingHandshakeCancel = null; + } + try { + subscription?.unsub?.(); + } catch (error) { + // ignore subscription cleanup failures + } + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + const cancelHandshake = () => { + cleanup(); + const error = new Error("Remote signer connection cancelled."); + error.code = "login-cancelled"; + reject(error); + }; + + this.pendingHandshakeCancel = cancelHandshake; + + const timeoutId = setTimeout(() => { + cleanup(); + const error = new Error("Timed out waiting for the remote signer to acknowledge the connection."); + error.code = "nip46-handshake-timeout"; + devLogger.warn("[nostr] Handshake wait timed out", { + clientPubkey: summarizeHexForLog(normalizedClientPublicKey), + relays: resolvedRelays, + }); + reject(error); + }, waitTimeout); + + let subscription; + try { + subscription = pool.sub(resolvedRelays, filters); + devLogger.debug("[nostr] Handshake subscription established", { + relays: resolvedRelays, + filters, + }); + } catch (error) { + cleanup(); + devLogger.warn("[nostr] Failed to subscribe for handshake responses", error); + reject(error); + return; + } + + subscription.on("event", (event) => { + if (settled) { + return; + } + + if (!event || event.kind !== this.deps.NIP46_RPC_KIND) { + devLogger.debug("[nostr] Ignoring non-handshake event during wait", event); + return; + } + + const eventRemotePubkey = this.deps.normalizeNostrPubkey(event.pubkey); + const candidateRemotePubkeys = []; + if (expectedRemotePubkey) { + candidateRemotePubkeys.push(expectedRemotePubkey); + } + if (eventRemotePubkey) { + candidateRemotePubkeys.push(eventRemotePubkey); + } + + devLogger.debug("[nostr] Processing handshake event", { + eventId: typeof event.id === "string" ? event.id : "", + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + candidateRemotePubkeys: candidateRemotePubkeys.map((key) => + summarizeHexForLog(key), + ), + contentLength: typeof event.content === "string" ? event.content.length : 0, + }); + + Promise.resolve() + .then(() => + this.deps.attemptDecryptNip46HandshakePayload({ + clientPrivateKey, + candidateRemotePubkeys, + ciphertext: event.content, + }), + ) + .then((payloadResult) => { + const plaintext = payloadResult?.plaintext ?? ""; + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error); + return; + } + + devLogger.debug("[nostr] Handshake payload parsed", { + remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || ""), + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + algorithm: payloadResult?.algorithm || null, + requestId: typeof parsed?.id === "string" ? parsed.id : "", + hasResult: parsed?.result !== undefined, + hasError: parsed?.error !== undefined, + }); + + const resultValue = coerceStructuredString(parsed?.result); + const errorValue = coerceStructuredString(parsed?.error); + + if (resultValue === "auth_url" && errorValue) { + const handshakeRemotePubkey = payloadResult?.remotePubkey || eventRemotePubkey || ""; + devLogger.debug("[nostr] Handshake provided auth_url challenge", { + eventId: typeof parsed?.id === "string" ? parsed.id : "", + remotePubkey: summarizeHexForLog(handshakeRemotePubkey), + url: summarizeUrlForLog(errorValue), + }); + if (typeof onAuthUrl === "function") { + try { + onAuthUrl(errorValue, { + phase: "handshake", + remotePubkey: handshakeRemotePubkey, + requestId: typeof parsed?.id === "string" ? parsed.id : "", + }); + } catch (callbackError) { + devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError); + } + } + return; + } + + if (secret) { + const normalizedResult = resultValue ? resultValue.toLowerCase() : ""; + if (resultValue !== secret && normalizedResult !== "ack") { + return; + } + } + + if (!secret && resultValue) { + const normalized = resultValue.toLowerCase(); + if (!["ack", "ok", "success"].includes(normalized)) { + 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: this.deps.normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), + }); + + devLogger.debug("[nostr] Handshake wait resolved", { + remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || eventRemotePubkey || ""), + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + result: resultValue || null, + hasSecret: Boolean(secret), + }); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to decrypt remote signer handshake payload:", error); + }); + }); + + subscription.on("eose", () => { + // no-op: handshake responses are push-based + }); + }); + } + + /** + * Establishes a NIP-46 connection. + * + * @param {object} params + * @returns {Promise<{client: Nip46RpcClient, signer: object, userPubkey: string}>} + */ + async connect({ + connectionString, + remember = true, + clientPrivateKey: providedClientPrivateKey = "", + clientPublicKey: providedClientPublicKey = "", + relays: providedRelays = [], + secret: providedSecret = "", + permissions: providedPermissions = "", + metadata: providedMetadata = {}, + onAuthUrl, + onStatus, + handshakeTimeoutMs, + passphrase, + validator, + } = {}) { + const parsed = this.deps.parseNip46ConnectionString(connectionString); + if (!parsed) { + const error = new Error( + "Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.", + ); + error.code = "invalid-connection-string"; + throw error; + } + + const baseMetadata = this.deps.sanitizeNip46Metadata(parsed.metadata); + const overrideMetadata = this.deps.sanitizeNip46Metadata(providedMetadata); + const metadata = { ...baseMetadata, ...overrideMetadata }; + + devLogger.debug("[nostr] Connecting to remote signer", { + connectionType: parsed.type, + parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""), + providedClientPublicKey: summarizeHexForLog(providedClientPublicKey), + providedClientPrivateKey: summarizeSecretForLog( + typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "", + ), + providedSecret: summarizeSecretForLog(providedSecret), + providedPermissions: providedPermissions || null, + handshakeTimeoutMs, + parsedRelays: parsed.relays, + overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0, + metadataKeys: summarizeMetadataForLog(metadata), + }); + + const handleStatus = (status) => { + if (typeof onStatus !== "function") { + return; + } + try { + onStatus(status); + } catch (error) { + devLogger.warn("[nostr] Remote signer status callback threw:", error); + } + }; + + const handleAuthChallenge = async (url, context = {}) => { + if (typeof onAuthUrl !== "function" || !url) { + return; + } + try { + devLogger.debug("[nostr] Remote signer auth challenge surfaced", { + url: summarizeUrlForLog(url), + context, + }); + const result = onAuthUrl(url, context); + if (result && typeof result.then === "function") { + await result.catch((error) => { + devLogger.warn("[nostr] Auth challenge callback promise rejected:", error); + }); + } + } catch (error) { + devLogger.warn("[nostr] Remote signer auth callback threw:", error); + } + }; + + const mergedRelaysSource = parsed.relays.length ? parsed.relays : providedRelays; + const relays = this.deps.resolveNip46Relays(mergedRelaysSource, this.nostrClient.relays); + + let secret = typeof providedSecret === "string" && providedSecret.trim() ? providedSecret.trim() : parsed.secret; + let permissions = + typeof providedPermissions === "string" && providedPermissions.trim() + ? providedPermissions.trim() + : parsed.permissions; + + let clientPrivateKey = ""; + let clientPublicKey = ""; + let remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey); + let handshakeAlgorithm = ""; + + if (parsed.type === "client") { + clientPrivateKey = + typeof providedClientPrivateKey === "string" && providedClientPrivateKey.trim() + ? providedClientPrivateKey.trim().toLowerCase() + : ""; + + if (!clientPrivateKey || !HEX64_REGEX.test(clientPrivateKey)) { + const error = new Error( + "Remote signer handshake requires the generated client private key.", + ); + error.code = "missing-client-private-key"; + throw error; + } + + clientPublicKey = this.deps.normalizeNostrPubkey(providedClientPublicKey) || parsed.clientPubkey; + if (!clientPublicKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable for the remote signer handshake."); + } + clientPublicKey = this.deps.normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey)); + } + + if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) { + const error = new Error("Invalid client public key for the remote signer handshake."); + error.code = "invalid-client-public-key"; + throw error; + } + + if (parsed.clientPubkey && this.deps.normalizeNostrPubkey(parsed.clientPubkey) !== clientPublicKey) { + const error = new Error("Handshake public key mismatch detected."); + error.code = "client-public-key-mismatch"; + throw error; + } + + if (!secret) { + secret = this.deps.generateNip46Secret(); + } + + handleStatus({ + phase: "handshake", + state: "waiting", + message: "Waiting for the signer to acknowledge the connection…", + relays, + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + relays, + metadata, + remotePubkey: parsed.remotePubkey || "", + userPubkey: parsed.userPubkeyHint || "", + message: "Waiting for the signer to acknowledge the connection.", + }); + + let handshakeResult; + try { + handshakeResult = await this.waitForHandshake({ + clientPrivateKey, + clientPublicKey, + relays, + secret, + onAuthUrl: (url, context) => handleAuthChallenge(url, context), + onStatus: handleStatus, + timeoutMs: handshakeTimeoutMs, + expectedRemotePubkey: parsed.remotePubkey, + }); + devLogger.debug("[nostr] Remote signer handshake completed", { + remotePubkey: summarizeHexForLog(handshakeResult?.remotePubkey || parsed.remotePubkey || ""), + algorithm: handshakeResult?.algorithm || null, + relays, + secret: summarizeSecretForLog(secret), + }); + } catch (error) { + this.nostrClient.emitRemoteSignerChange({ + state: "error", + relays, + metadata, + message: error?.message || "Remote signer handshake failed.", + error, + }); + throw error; + } + + remotePubkey = this.deps.normalizeNostrPubkey(handshakeResult?.remotePubkey); + handshakeAlgorithm = this.deps.normalizeNip46EncryptionAlgorithm( + handshakeResult?.algorithm, + ); + if (!remotePubkey) { + const error = new Error("Remote signer did not return a valid public key."); + error.code = "missing-remote-pubkey"; + throw error; + } + devLogger.debug("[nostr] Remote signer handshake provided final pubkey", { + remotePubkey: summarizeHexForLog(remotePubkey), + algorithm: handshakeAlgorithm || null, + clientPublicKey: summarizeHexForLog(clientPublicKey), + }); + } else { + const keyPair = await this.createKeyPair( + providedClientPrivateKey, + providedClientPublicKey, + ); + clientPrivateKey = keyPair.privateKey; + clientPublicKey = keyPair.publicKey; + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + remotePubkey, + relays, + metadata, + userPubkey: parsed.userPubkeyHint || "", + }); + devLogger.debug("[nostr] Using bunker URI for remote signer connect", { + remotePubkey: summarizeHexForLog(remotePubkey || parsed.remotePubkey || ""), + relays, + metadataKeys: summarizeMetadataForLog(metadata), + }); + } + + if (!remotePubkey) { + remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey); + } + + if (!remotePubkey) { + const error = new Error("Remote signer pubkey is required to establish the session."); + error.code = "missing-remote-pubkey"; + throw error; + } + + const client = new this.deps.Nip46RpcClient({ + nostrClient: this.nostrClient, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + secret, + permissions, + metadata, + encryption: handshakeAlgorithm, + signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey), + }); + + devLogger.debug("[nostr] Remote signer RPC client created", { + clientPublicKey: summarizeHexForLog(client.clientPublicKey), + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + permissions, + secret: summarizeSecretForLog(secret), + metadataKeys: summarizeMetadataForLog(metadata), + handshakeAlgorithm, + }); + + try { + await client.ensureSubscription(); + + devLogger.debug("[nostr] Remote signer subscription ready", { + remotePubkey: summarizeHexForLog(remotePubkey), + relayCount: relays.length, + }); + + handleStatus({ + phase: "connect", + state: "request", + message: "Requesting approval from the remote signer…", + remotePubkey, + }); + + let attempts = 0; + // Attempt the connect RPC, handling auth challenges when provided. + for (;;) { + try { + devLogger.debug("[nostr] Sending NIP-46 connect request", { + remotePubkey: summarizeHexForLog(remotePubkey), + attempt: attempts + 1, + permissions: permissions || null, + }); + await client.connect({ permissions }); + break; + } catch (error) { + devLogger.warn("[nostr] Connect RPC attempt failed", { + remotePubkey: summarizeHexForLog(remotePubkey), + attempt: attempts + 1, + code: error?.code || null, + message: error?.message || String(error), + }); + if (error?.code === "auth-challenge" && error.authUrl) { + attempts += 1; + handleStatus({ + phase: "auth", + state: "waiting", + message: "Complete the authentication challenge in your signer…", + remotePubkey, + attempt: attempts, + }); + await handleAuthChallenge(error.authUrl, { + phase: "connect", + remotePubkey, + attempt: attempts, + }); + + if (attempts >= this.deps.NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS) { + throw error; + } + continue; + } + throw error; + } + } + + const userPubkey = await client.getUserPubkey(); + devLogger.debug("[nostr] Retrieved user pubkey from remote signer", { + remotePubkey: summarizeHexForLog(remotePubkey), + userPubkey: summarizeHexForLog(userPubkey), + }); + + if (typeof validator === "function") { + try { + const isValid = await validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); // Cleanup client since we won't use it + throw error; + } + } + + client.metadata = metadata; + + if (remember) { + devLogger.debug("[nostr] Persisting remote signer session", { + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions: permissions || null, + }); + await this.deps.writeStoredNip46Session( + { + version: 1, + clientPublicKey, + remotePubkey, + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions, + metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey, + secret, + }, + passphrase, + ); + } else { + devLogger.debug( + "[nostr] Clearing stored remote signer session per request", + ); + this.deps.clearStoredNip46Session(); + } + + this.nostrClient.emitRemoteSignerChange({ + state: "connected", + remotePubkey, + userPubkey, + relays, + metadata, + }); + + handleStatus({ + phase: "connected", + state: "ready", + message: "Remote signer connected successfully.", + remotePubkey, + userPubkey, + }); + + devLogger.debug("[nostr] Remote signer connection established", { + remotePubkey: summarizeHexForLog(remotePubkey), + userPubkey: summarizeHexForLog(userPubkey), + relays, + permissions: permissions || null, + secret: summarizeSecretForLog(secret), + }); + + return { pubkey: userPubkey, client }; + } catch (error) { + devLogger.error("[nostr] Remote signer connection failed", { + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + permissions: permissions || null, + secret: summarizeSecretForLog(secret), + message: error?.message || String(error), + code: error?.code || null, + }); + client.destroy(); + if (!remember) { + this.deps.clearStoredNip46Session(); + } + this.nostrClient.emitRemoteSignerChange({ + state: "error", + remotePubkey, + relays, + metadata, + message: error?.message || "Remote signer connection failed.", + error, + }); + throw error; + } + } + + /** + * Reconnects to a stored session. + * + * @param {object} [options] + * @returns {Promise<{pubkey: string, client: Nip46RpcClient}>} + */ + async reconnectStored(options = {}) { + const normalizedOptions = + options && typeof options === "object" ? options : {}; + const silent = normalizedOptions.silent === true; + const forgetOnError = normalizedOptions.forgetOnError === true; + const passphrase = + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; + + let stored = this.deps.readStoredNip46Session(); + if (!stored) { + const error = new Error( + "No remote signer session is stored on this device.", + ); + error.code = "no-stored-session"; + throw error; + } + + if (stored.encryptedSecrets) { + if (!passphrase) { + const error = new Error("Passphrase required to unlock session."); + error.code = "passphrase-required"; + throw error; + } + try { + stored = await this.deps.decryptNip46Session(stored, passphrase); + } catch (decryptError) { + const error = new Error("Failed to decrypt stored session."); + error.code = "decrypt-failed"; + error.cause = decryptError; + throw error; + } + } + + const relays = this.deps.resolveNip46Relays(stored.relays, this.nostrClient.relays); + + devLogger.debug("[nostr] Reconnecting to stored remote signer", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + relays, + encryption: stored.encryption || "", + permissions: stored.permissions || null, + hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + remotePubkey: stored.remotePubkey, + relays, + metadata: stored.metadata, + }); + + if (!stored.clientPrivateKey || !stored.secret) { + const error = new Error( + "Stored remote signer credentials are unavailable.", + ); + error.code = "stored-session-missing-credentials"; + throw error; + } + + const client = new this.deps.Nip46RpcClient({ + nostrClient: this.nostrClient, + clientPrivateKey: stored.clientPrivateKey, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays, + encryption: stored.encryption, + secret: stored.secret, + permissions: stored.permissions, + metadata: stored.metadata, + signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey), + }); + + try { + await client.ensureSubscription(); + await client.connect({ permissions: stored.permissions }); + const userPubkey = await client.getUserPubkey(); + + if (validator) { + try { + const isValid = await validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); + throw error; + } + } + + client.metadata = stored.metadata; + + await this.deps.writeStoredNip46Session( + { + version: 1, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays: stored.relays, + encryption: client.encryptionAlgorithm || stored.encryption || "", + permissions: stored.permissions, + metadata: stored.metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey: stored.clientPrivateKey, + secret: stored.secret, + }, + passphrase, + ); + + devLogger.debug("[nostr] Stored remote signer session refreshed", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + userPubkey: summarizeHexForLog(userPubkey), + encryption: client.encryptionAlgorithm || stored.encryption || "", + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connected", + remotePubkey: stored.remotePubkey, + userPubkey, + relays, + metadata: stored.metadata, + }); + + return { pubkey: userPubkey, client }; + } catch (error) { + await client.destroy().catch(() => {}); + const fatalCodes = new Set([ + "nip46-secret-mismatch", + "invalid-private-key", + "invalid-connection-string", + ]); + const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code); + if (shouldForgetStored) { + this.deps.clearStoredNip46Session(); + devLogger.warn("[nostr] Stored remote signer session cleared after failure", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + error: error?.message || String(error), + code: error?.code || null, + }); + } + + const status = { + state: shouldForgetStored ? "idle" : silent ? "stored" : "error", + remotePubkey: stored.remotePubkey, + relays, + metadata: stored.metadata, + }; + + if (!silent || shouldForgetStored) { + status.message = + error?.message || "Failed to reconnect to the remote signer."; + } + status.error = error; + + this.nostrClient.emitRemoteSignerChange(status); + + if (silent) { + devLogger.log("[nostr] Silent remote signer restore failed:", error); + } else { + devLogger.warn( + "[nostr] Stored remote signer reconnection failed:", + error, + ); + } + throw error; + } + } + + async abort() { + if (this.pendingHandshakeCancel) { + try { + this.pendingHandshakeCancel(); + } catch (error) { + devLogger.warn("[nostr] Failed to cancel pending handshake:", error); + } + this.pendingHandshakeCancel = null; + } + } + + async disconnect({ keepStored = true } = {}) { + await this.abort(); + + if (!keepStored) { + this.deps.clearStoredNip46Session(); + } + } +} diff --git a/tests/nostr/nip46Connector.test.mjs b/tests/nostr/nip46Connector.test.mjs new file mode 100644 index 00000000..cdb4753a --- /dev/null +++ b/tests/nostr/nip46Connector.test.mjs @@ -0,0 +1,164 @@ + +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { Nip46Connector } from "../../js/nostr/nip46Connector.js"; + +const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; +const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002"; + +// Mock dependencies +const mockDeps = { + parseNip46ConnectionString: mock.fn(), + generateNip46Secret: mock.fn(), + readStoredNip46Session: mock.fn(), + writeStoredNip46Session: mock.fn(), + clearStoredNip46Session: mock.fn(), + normalizeNip46EncryptionAlgorithm: mock.fn(), + decryptNip46Session: mock.fn(), + attemptDecryptNip46HandshakePayload: mock.fn(), + sanitizeNip46Metadata: mock.fn((m) => m || {}), + resolveNip46Relays: mock.fn((r) => r || []), + normalizeNostrPubkey: mock.fn((k) => k), + encodeHexToNpub: mock.fn((k) => "npub" + k), + bytesToHex: mock.fn((b) => VALID_HEX_KEY), + signEventWithPrivateKey: mock.fn(), + Nip46RpcClient: class MockRpcClient { + constructor() { + this.destroy = mock.fn(); + this.ensureSubscription = mock.fn(() => Promise.resolve()); + this.connect = mock.fn(() => Promise.resolve()); + this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY)); + this.metadata = {}; + } + }, + ensureNostrTools: mock.fn(async () => ({ + generateSecretKey: () => new Uint8Array(32), + getPublicKey: () => VALID_PUBKEY, + })), + getCachedNostrTools: mock.fn(() => ({ + generateSecretKey: () => new Uint8Array(32), + getPublicKey: () => VALID_PUBKEY, + })), + NIP46_RPC_KIND: 24133, + NIP46_HANDSHAKE_TIMEOUT_MS: 100, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3, +}; + +describe("Nip46Connector", () => { + let connector; + let mockNostrClient; + + beforeEach(() => { + // Reset mocks + Object.values(mockDeps).forEach((m) => { + if (m.mock) m.mock.resetCalls(); + }); + + mockNostrClient = { + pool: { + publish: mock.fn(() => Promise.resolve()), + sub: mock.fn(() => ({ + on: mock.fn(), + unsub: mock.fn(), + })), + }, + ensurePool: mock.fn(async () => mockNostrClient.pool), + connectToRelays: mock.fn(async () => []), + emitRemoteSignerChange: mock.fn(), + relays: ["wss://relay.example.com"], + }; + + connector = new Nip46Connector(mockNostrClient, mockDeps); + }); + + describe("createKeyPair", () => { + it("should generate a new key pair if none provided", async () => { + mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret"); + + const result = await connector.createKeyPair(); + + assert.deepEqual(result, { + privateKey: VALID_HEX_KEY, + publicKey: VALID_PUBKEY, + }); + }); + + it("should use existing keys if provided", async () => { + const result = await connector.createKeyPair( + VALID_HEX_KEY, + VALID_PUBKEY + ); + assert.deepEqual(result, { + privateKey: VALID_HEX_KEY, + publicKey: VALID_PUBKEY, + }); + }); + }); + + describe("prepareHandshake", () => { + it("should parse connection string and return details", async () => { + mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret"); + + const result = await connector.prepareHandshake({ + connectionString: "bunker://...", + }); + + assert.equal(result.clientPrivateKey, VALID_HEX_KEY); + assert.equal(result.secret, "handshake-secret"); + assert.match(result.connectionString, /^nostrconnect:\/\//); + }); + }); + + describe("connect", () => { + it("should connect directly if remote pubkey is known", async () => { + const options = { + connectionString: "bunker://remote-pub?relay=wss://relay.com", + clientPrivateKey: VALID_HEX_KEY, + }; + + mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({ + relays: ["wss://relay.com"], + remotePubkey: VALID_PUBKEY, + type: "bunker", + })); + mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44"); + mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k); + + const result = await connector.connect(options); + + assert.equal(result.pubkey, VALID_PUBKEY); + assert.ok(result.client instanceof mockDeps.Nip46RpcClient); + assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1); + }); + }); + + describe("reconnectStored", () => { + it("should throw if no stored session", async () => { + mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null); + await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/); + }); + + it("should reconnect using stored session", async () => { + mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({ + userPubkey: "user-pub", + remotePubkey: "remote-pub", + relays: [], + clientPrivateKey: "client-priv", + secret: "secret", + })); + mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s); + + const result = await connector.reconnectStored(); + + assert.equal(result.pubkey, VALID_PUBKEY); + assert.ok(result.client instanceof mockDeps.Nip46RpcClient); + }); + }); + + describe("disconnect", () => { + it("should clear stored session and emit change", async () => { + await connector.disconnect({ keepStored: false }); + assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1); + }); + }); +});