diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index b2c635b5..30143156 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -24,8 +24,21 @@ import { getActiveSigner as getActiveSignerFromRegistry, clearActiveSigner as clearActiveSignerInRegistry, logoutSigner as logoutSignerFromRegistry, - resolveActiveSigner as resolveActiveSignerFromRegistry, } from "../../nostrClientRegistry.js"; +import { + resolveSignerCapabilities, + hydrateExtensionSignerCapabilities, + attachNipMethodAliases, + resolveActiveSigner, +} from "../signerCapabilities.js"; + +// Re-exported for existing importers (e.g. js/nostr/client.js) that historically +// imported these from SignerManager. +export { + resolveSignerCapabilities, + hydrateExtensionSignerCapabilities, + attachNipMethodAliases, +}; import { readStoredNip46Session, @@ -59,206 +72,7 @@ import { queueSignEvent } from "../signRequestQueue.js"; import { signEventWithPrivateKey } from "../publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; import { accessControl } from "../../accessControl.js"; - -export function resolveSignerCapabilities(signer) { - const fallback = { - sign: false, - nip44: false, - nip04: false, - }; - - if (!signer || typeof signer !== "object") { - return fallback; - } - - const capabilities = - signer.capabilities && typeof signer.capabilities === "object" - ? signer.capabilities - : {}; - - return { - sign: - (typeof capabilities.sign === "boolean" && capabilities.sign) || - typeof signer.signEvent === "function", - nip44: - (typeof capabilities.nip44 === "boolean" && capabilities.nip44) || - typeof signer.nip44Encrypt === "function" || - typeof signer.nip44Decrypt === "function", - nip04: - (typeof capabilities.nip04 === "boolean" && capabilities.nip04) || - typeof signer.nip04Encrypt === "function" || - typeof signer.nip04Decrypt === "function", - }; -} - -export function hydrateExtensionSignerCapabilities(signer) { - if (!signer || typeof signer !== "object") { - return; - } - - const signerType = typeof signer.type === "string" ? signer.type : ""; - if (signerType !== "extension" && signerType !== "nip07") { - return; - } - - const extension = - typeof window !== "undefined" && window && window.nostr ? window.nostr : null; - if (!extension) { - return; - } - - if (typeof signer.signEvent !== "function" && extension.signEvent) { - if (typeof extension.signEvent === "function") { - signer.signEvent = extension.signEvent.bind(extension); - } - } - - if (!signer.nip04 && extension.nip04) { - signer.nip04 = extension.nip04; - } - - if (!signer.nip44 && extension.nip44) { - signer.nip44 = extension.nip44; - } -} - -export function attachNipMethodAliases(signer) { - if (!signer || typeof signer !== "object") { - return; - } - - const nip04 = - signer && typeof signer.nip04 === "object" && signer.nip04 !== null - ? signer.nip04 - : null; - if (nip04) { - const encrypt = - typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null; - const decrypt = - typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null; - - if (encrypt && typeof signer.nip04Encrypt !== "function") { - signer.nip04Encrypt = (targetPubkey, plaintext) => - encrypt(targetPubkey, plaintext); - } - - if (decrypt && typeof signer.nip04Decrypt !== "function") { - signer.nip04Decrypt = (actorPubkey, ciphertext) => - decrypt(actorPubkey, ciphertext); - } - } - - const nip44 = - signer && typeof signer.nip44 === "object" && signer.nip44 !== null - ? signer.nip44 - : null; - if (nip44) { - const v2 = - typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null; - - const encrypt = (() => { - if (typeof signer.nip44Encrypt === "function") { - return null; - } - if (typeof v2?.encrypt === "function") { - return v2.encrypt.bind(v2); - } - if (typeof nip44.encrypt === "function") { - return nip44.encrypt.bind(nip44); - } - return null; - })(); - - const decrypt = (() => { - if (typeof signer.nip44Decrypt === "function") { - return null; - } - if (typeof v2?.decrypt === "function") { - return v2.decrypt.bind(v2); - } - if (typeof nip44.decrypt === "function") { - return nip44.decrypt.bind(nip44); - } - return null; - })(); - - if (encrypt) { - signer.nip44Encrypt = (targetPubkey, plaintext) => - encrypt(targetPubkey, plaintext); - } - - if (decrypt) { - signer.nip44Decrypt = (actorPubkey, ciphertext) => - decrypt(actorPubkey, ciphertext); - } - } -} - -function resolveActiveSigner(pubkey) { - const signer = resolveActiveSignerFromRegistry(pubkey); - hydrateExtensionSignerCapabilities(signer); - attachNipMethodAliases(signer); - if (signer && typeof signer === "object") { - const capsDescriptor = Object.getOwnPropertyDescriptor( - signer, - "capabilities", - ); - const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; - - if (!isGetter) { - signer.capabilities = resolveSignerCapabilities(signer); - } - } - return signer; -} - -const PERMISSION_STATUS_AUTO_HIDE_MS = 12_000; -const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."]; - -function hasEncryptionPermissionMethods(methods) { - if (!Array.isArray(methods)) { - return false; - } - return methods.some((method) => - ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)), - ); -} - -function hasSigningPermissionMethods(methods) { - if (!Array.isArray(methods)) { - return false; - } - return methods.some( - (method) => method === "sign_event" || method === "get_public_key", - ); -} - -function resolvePermissionStatusMessage(methods, context) { - const normalizedContext = - typeof context === "string" ? context.trim().toLowerCase() : ""; - - if (normalizedContext === "dm") { - return "Approve the extension prompt to enable encrypted direct messages."; - } - if (normalizedContext === "lists") { - return "Approve the extension prompt to access your encrypted lists."; - } - - const includesEncryption = hasEncryptionPermissionMethods(methods); - const includesSigning = hasSigningPermissionMethods(methods); - - if (includesEncryption && includesSigning) { - return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists)."; - } - if (includesEncryption) { - return "Approve the extension prompt to enable encrypted features like DMs and private lists."; - } - if (includesSigning) { - return "Approve the extension prompt to enable signing."; - } - - return "Approve the extension prompt to continue."; -} +import { waitForRemoteSignerHandshake } from "../signerRemoteHandshake.js"; export class SignerManager { constructor(client) { @@ -754,192 +568,8 @@ export class SignerManager { * * @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 _waitForRemoteSignerHandshake(options = {}) { + return waitForRemoteSignerHandshake(this, options); } async connectRemoteSigner({ diff --git a/js/nostr/signerCapabilities.js b/js/nostr/signerCapabilities.js new file mode 100644 index 00000000..ace2ace0 --- /dev/null +++ b/js/nostr/signerCapabilities.js @@ -0,0 +1,207 @@ +// js/nostr/signerCapabilities.js +// +// Signer capability/permission helpers extracted from managers/SignerManager.js +// to keep that module under the file-size budget. These operate on a signer +// object (or plain method lists) passed in — no SignerManager instance state — +// so they move cleanly. No behavior change. + +import { resolveActiveSigner as resolveActiveSignerFromRegistry } from "../nostrClientRegistry.js"; + +export function resolveSignerCapabilities(signer) { + const fallback = { + sign: false, + nip44: false, + nip04: false, + }; + + if (!signer || typeof signer !== "object") { + return fallback; + } + + const capabilities = + signer.capabilities && typeof signer.capabilities === "object" + ? signer.capabilities + : {}; + + return { + sign: + (typeof capabilities.sign === "boolean" && capabilities.sign) || + typeof signer.signEvent === "function", + nip44: + (typeof capabilities.nip44 === "boolean" && capabilities.nip44) || + typeof signer.nip44Encrypt === "function" || + typeof signer.nip44Decrypt === "function", + nip04: + (typeof capabilities.nip04 === "boolean" && capabilities.nip04) || + typeof signer.nip04Encrypt === "function" || + typeof signer.nip04Decrypt === "function", + }; +} + +export function hydrateExtensionSignerCapabilities(signer) { + if (!signer || typeof signer !== "object") { + return; + } + + const signerType = typeof signer.type === "string" ? signer.type : ""; + if (signerType !== "extension" && signerType !== "nip07") { + return; + } + + const extension = + typeof window !== "undefined" && window && window.nostr ? window.nostr : null; + if (!extension) { + return; + } + + if (typeof signer.signEvent !== "function" && extension.signEvent) { + if (typeof extension.signEvent === "function") { + signer.signEvent = extension.signEvent.bind(extension); + } + } + + if (!signer.nip04 && extension.nip04) { + signer.nip04 = extension.nip04; + } + + if (!signer.nip44 && extension.nip44) { + signer.nip44 = extension.nip44; + } +} + +export function attachNipMethodAliases(signer) { + if (!signer || typeof signer !== "object") { + return; + } + + const nip04 = + signer && typeof signer.nip04 === "object" && signer.nip04 !== null + ? signer.nip04 + : null; + if (nip04) { + const encrypt = + typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null; + const decrypt = + typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null; + + if (encrypt && typeof signer.nip04Encrypt !== "function") { + signer.nip04Encrypt = (targetPubkey, plaintext) => + encrypt(targetPubkey, plaintext); + } + + if (decrypt && typeof signer.nip04Decrypt !== "function") { + signer.nip04Decrypt = (actorPubkey, ciphertext) => + decrypt(actorPubkey, ciphertext); + } + } + + const nip44 = + signer && typeof signer.nip44 === "object" && signer.nip44 !== null + ? signer.nip44 + : null; + if (nip44) { + const v2 = + typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null; + + const encrypt = (() => { + if (typeof signer.nip44Encrypt === "function") { + return null; + } + if (typeof v2?.encrypt === "function") { + return v2.encrypt.bind(v2); + } + if (typeof nip44.encrypt === "function") { + return nip44.encrypt.bind(nip44); + } + return null; + })(); + + const decrypt = (() => { + if (typeof signer.nip44Decrypt === "function") { + return null; + } + if (typeof v2?.decrypt === "function") { + return v2.decrypt.bind(v2); + } + if (typeof nip44.decrypt === "function") { + return nip44.decrypt.bind(nip44); + } + return null; + })(); + + if (encrypt) { + signer.nip44Encrypt = (targetPubkey, plaintext) => + encrypt(targetPubkey, plaintext); + } + + if (decrypt) { + signer.nip44Decrypt = (actorPubkey, ciphertext) => + decrypt(actorPubkey, ciphertext); + } + } +} + +export function resolveActiveSigner(pubkey) { + const signer = resolveActiveSignerFromRegistry(pubkey); + hydrateExtensionSignerCapabilities(signer); + attachNipMethodAliases(signer); + if (signer && typeof signer === "object") { + const capsDescriptor = Object.getOwnPropertyDescriptor( + signer, + "capabilities", + ); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } + } + return signer; +} + +const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."]; + +export function hasEncryptionPermissionMethods(methods) { + if (!Array.isArray(methods)) { + return false; + } + return methods.some((method) => + ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)), + ); +} + +export function hasSigningPermissionMethods(methods) { + if (!Array.isArray(methods)) { + return false; + } + return methods.some( + (method) => method === "sign_event" || method === "get_public_key", + ); +} + +export function resolvePermissionStatusMessage(methods, context) { + const normalizedContext = + typeof context === "string" ? context.trim().toLowerCase() : ""; + + if (normalizedContext === "dm") { + return "Approve the extension prompt to enable encrypted direct messages."; + } + if (normalizedContext === "lists") { + return "Approve the extension prompt to access your encrypted lists."; + } + + const includesEncryption = hasEncryptionPermissionMethods(methods); + const includesSigning = hasSigningPermissionMethods(methods); + + if (includesEncryption && includesSigning) { + return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists)."; + } + if (includesEncryption) { + return "Approve the extension prompt to enable encrypted features like DMs and private lists."; + } + if (includesSigning) { + return "Approve the extension prompt to enable signing."; + } + + return "Approve the extension prompt to continue."; +} diff --git a/js/nostr/signerRemoteHandshake.js b/js/nostr/signerRemoteHandshake.js new file mode 100644 index 00000000..ba8cd3c4 --- /dev/null +++ b/js/nostr/signerRemoteHandshake.js @@ -0,0 +1,208 @@ +// js/nostr/signerRemoteHandshake.js +// +// NIP-46 remote-signer handshake wait, extracted from managers/SignerManager.js +// to keep that module under the file-size budget. Takes the SignerManager +// instance (`ctx`) for its client/pool and cancel hook, so behavior is identical +// to the former method; SignerManager keeps a thin delegator. No behavior change. + +import { + normalizeNip46EncryptionAlgorithm, + resolveNip46Relays, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + attemptDecryptNip46HandshakePayload, + normalizeNostrPubkey, +} from "./nip46Client.js"; +import { devLogger } from "../utils/logger.js"; +import { HEX64_REGEX } from "../utils/hex.js"; + +export async function waitForRemoteSignerHandshake( + ctx, + { + 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, ctx.client?.relays); + if (!resolvedRelays.length) { + throw new Error("No relays available to complete the remote signer handshake."); + } + + const pool = await ctx.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; + ctx.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. + ctx.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 = + ctx._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 + }); + }); +}