diff --git a/js/nostr.js b/js/nostr.js index fa98de07..73303b41 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -71,190 +71,50 @@ import { persistSessionActor as persistSessionActorEntry, readStoredSessionActorEntry, } from "./nostr/sessionActor.js"; +import { + HEX64_REGEX, + NIP46_RPC_KIND, + NIP46_SESSION_STORAGE_KEY, + NIP46_PUBLISH_TIMEOUT_MS, + NIP46_RESPONSE_TIMEOUT_MS, + NIP46_SIGN_EVENT_TIMEOUT_MS, + NIP46_MAX_RETRIES, + NIP46_HANDSHAKE_TIMEOUT_MS, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS, + sanitizeRelayList, + readStoredNip46Session, + writeStoredNip46Session, + clearStoredNip46Session, + parseNip46ConnectionString, + generateNip46Secret, + sanitizeNip46Metadata, + normalizeNip46EncryptionAlgorithm, + resolveNip46Relays, + normalizeNip46CiphertextPayload, + createNip46Cipher, + decryptNip46PayloadWithKeys, + attemptDecryptNip46HandshakePayload, + Nip46RpcClient, + createNip46RequestId, + decodeNpubToHex, + encodeHexToNpub, + normalizeNostrPubkey, + summarizeHexForLog, + summarizeSecretForLog, + summarizeMetadataForLog, + summarizeUrlForLog, + summarizePayloadPreviewForLog, + summarizeRpcParamsForLog, + summarizeRpcResultForLog, + summarizeRelayPublishResultsForLog, +} from "./nostr/nip46Client.js"; let activeSigner = null; const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const NIP46_RPC_KIND = 24_133; -const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; -const NIP46_PUBLISH_TIMEOUT_MS = 8_000; -const NIP46_RESPONSE_TIMEOUT_MS = 15_000; -const NIP46_SIGN_EVENT_TIMEOUT_MS = 20_000; -const NIP46_MAX_RETRIES = 1; -const NIP46_HANDSHAKE_TIMEOUT_MS = 60_000; -const NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS = 5; -const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([ - "nip44.v2", - "nip44", - "nip04", -]); -function getNip46Storage() { - if (typeof localStorage !== "undefined" && localStorage) { - return localStorage; - } - - if (typeof globalThis !== "undefined" && globalThis?.localStorage) { - return globalThis.localStorage; - } - - return null; -} - -function sanitizeStoredNip46Session(candidate) { - if (!candidate || typeof candidate !== "object") { - return null; - } - - const version = Number(candidate.version) || 0; - if (version !== 1) { - return null; - } - - const clientPrivateKey = - typeof candidate.clientPrivateKey === "string" && HEX64_REGEX.test(candidate.clientPrivateKey) - ? candidate.clientPrivateKey.toLowerCase() - : ""; - const clientPublicKey = - typeof candidate.clientPublicKey === "string" && candidate.clientPublicKey.trim() - ? candidate.clientPublicKey.trim().toLowerCase() - : ""; - const remotePubkey = - typeof candidate.remotePubkey === "string" && candidate.remotePubkey.trim() - ? candidate.remotePubkey.trim().toLowerCase() - : ""; - - if (!clientPrivateKey || !remotePubkey) { - return null; - } - - const relays = Array.isArray(candidate.relays) - ? candidate.relays - .map((relay) => (typeof relay === "string" ? relay.trim() : "")) - .filter(Boolean) - : []; - - const metadata = - candidate.metadata && typeof candidate.metadata === "object" - ? { - name: - typeof candidate.metadata.name === "string" - ? candidate.metadata.name.trim() - : "", - url: - typeof candidate.metadata.url === "string" - ? candidate.metadata.url.trim() - : "", - image: - typeof candidate.metadata.image === "string" - ? candidate.metadata.image.trim() - : "", - } - : {}; - - return { - version: 1, - clientPrivateKey, - clientPublicKey, - remotePubkey, - relays, - encryption: normalizeNip46EncryptionAlgorithm( - typeof candidate.encryption === "string" - ? candidate.encryption - : typeof candidate.algorithm === "string" - ? candidate.algorithm - : "", - ), - secret: - typeof candidate.secret === "string" && candidate.secret.trim() - ? candidate.secret.trim() - : "", - permissions: - typeof candidate.permissions === "string" && candidate.permissions.trim() - ? candidate.permissions.trim() - : "", - metadata, - userPubkey: - typeof candidate.userPubkey === "string" && candidate.userPubkey.trim() - ? candidate.userPubkey.trim().toLowerCase() - : "", - lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) - ? candidate.lastConnectedAt - : Date.now(), - }; -} - -function readStoredNip46Session() { - const storage = getNip46Storage(); - if (!storage) { - return null; - } - - let raw = null; - try { - raw = storage.getItem(NIP46_SESSION_STORAGE_KEY); - } catch (error) { - return null; - } - - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - return sanitizeStoredNip46Session(parsed); - } catch (error) { - try { - storage.removeItem(NIP46_SESSION_STORAGE_KEY); - } catch (cleanupError) { - devLogger.warn( - "[nostr] Failed to clear corrupt NIP-46 session entry:", - cleanupError, - ); - } - return null; - } -} - -function writeStoredNip46Session(payload) { - const storage = getNip46Storage(); - if (!storage) { - return; - } - - const normalized = sanitizeStoredNip46Session(payload); - if (!normalized) { - try { - storage.removeItem(NIP46_SESSION_STORAGE_KEY); - } catch (error) { - // ignore cleanup failures - } - return; - } - - try { - storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized)); - } catch (error) { - // ignore persistence failures - } -} - -function clearStoredNip46Session() { - const storage = getNip46Storage(); - if (!storage) { - return; - } - - try { - storage.removeItem(NIP46_SESSION_STORAGE_KEY); - } catch (error) { - // ignore cleanup issues - } -} -const HEX64_REGEX = /^[0-9a-f]{64}$/i; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; @@ -1164,50 +1024,6 @@ function getDTagValueFromTags(tags) { return ""; } -function sanitizeRelayList(list) { - const seen = new Set(); - const sanitized = []; - if (!Array.isArray(list)) { - return sanitized; - } - - list.forEach((value) => { - if (typeof value !== "string") { - return; - } - const trimmed = value.trim(); - if (!trimmed) { - return; - } - if (!/^wss?:\/\//i.test(trimmed)) { - return; - } - if (/\s/.test(trimmed)) { - return; - } - - let normalized = trimmed.replace(/\/+$/, ""); - try { - const parsed = new URL(trimmed); - if (!parsed.hostname) { - return; - } - const pathname = parsed.pathname.replace(/\/+$/, ""); - normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`; - } catch (error) { - // Ignore URL parsing failures and fall back to the trimmed string. - } - - if (seen.has(normalized)) { - return; - } - seen.add(normalized); - sanitized.push(normalized); - }); - - return sanitized; -} - function pointerKey(pointer) { if (!pointer || typeof pointer !== "object") { return ""; @@ -2706,1803 +2522,6 @@ function cloneEventForCache(event) { return cloned; } -function createNip46RequestId() { - try { - if (typeof crypto !== "undefined" && crypto?.getRandomValues) { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - } - } catch (error) { - // fall through to timestamp-based id - } - - const timestamp = Date.now().toString(16); - const entropy = Math.floor(Math.random() * 0xffffff) - .toString(16) - .padStart(6, "0"); - return `${timestamp}${entropy}`; -} - -function normalizeNostrPubkey(candidate) { - if (typeof candidate !== "string") { - return ""; - } - - const trimmed = candidate.trim(); - if (!trimmed) { - return ""; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - const decoded = decodeNpubToHex(trimmed); - if (decoded && HEX64_REGEX.test(decoded)) { - return decoded.toLowerCase(); - } - - return trimmed.toLowerCase(); -} - -function normalizeNip46EncryptionAlgorithm(value) { - if (typeof value !== "string") { - return ""; - } - - const trimmed = value.trim().toLowerCase(); - if (!trimmed) { - return ""; - } - - if (NIP46_ENCRYPTION_ALGORITHMS.includes(trimmed)) { - return trimmed; - } - - const aliasMap = new Map([ - ["nip44v2", "nip44.v2"], - ["nip44-v2", "nip44.v2"], - ["nip44_v2", "nip44.v2"], - ["nip44-v1", "nip44"], - ["nip44v1", "nip44"], - ["nip44_v1", "nip44"], - ["nip-44", "nip44"], - ["nip-04", "nip04"], - ["nip04.v1", "nip04"], - ]); - - if (aliasMap.has(trimmed)) { - return aliasMap.get(trimmed) || ""; - } - - return ""; -} - -function resolveNip46Relays(relays, fallbackRelays = []) { - const primary = sanitizeRelayList(Array.isArray(relays) ? relays : []); - if (primary.length) { - return primary; - } - - const fallback = sanitizeRelayList(Array.isArray(fallbackRelays) ? fallbackRelays : []); - if (fallback.length) { - return fallback; - } - - return Array.from(DEFAULT_RELAY_URLS); -} - -function parseNip46ConnectionString(uri) { - const value = typeof uri === "string" ? uri.trim() : ""; - if (!value) { - return null; - } - - let parsed; - try { - parsed = new URL(value); - } catch (error) { - return null; - } - - const decodeParam = (raw) => { - if (typeof raw !== "string") { - return ""; - } - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - try { - return decodeURIComponent(trimmed); - } catch (error) { - return trimmed; - } - }; - - const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); - const params = parsed.searchParams || new URLSearchParams(); - - const relays = params - .getAll("relay") - .map((relay) => decodeParam(relay)) - .filter(Boolean); - - const permissionsParam = - decodeParam(params.get("perms")) || decodeParam(params.get("permissions")) || ""; - const metadata = { - name: "", - url: "", - image: "", - }; - - for (const key of ["name", "url", "image"]) { - const decoded = decodeParam(params.get(key)); - if (decoded) { - metadata[key] = decoded; - } - } - - const remoteSignerParamKeys = [ - "remote-signer-key", - "remote_signer_key", - "remotesignerkey", - "remoteSignerKey", - "remote-signer-pubkey", - "remote_signer_pubkey", - "remoteSignerPubkey", - ]; - - let remoteSignerParam = ""; - for (const key of remoteSignerParamKeys) { - const candidate = decodeParam(params.get(key)); - if (candidate) { - remoteSignerParam = candidate; - break; - } - } - - const remoteFallbackParam = - decodeParam(params.get("remote")) || - decodeParam(params.get("remotePubkey")) || - decodeParam(params.get("signer")) || - ""; - - let remotePubkey = remoteSignerParam || remoteFallbackParam || ""; - let clientPubkey = ""; - let userPubkeyHint = ""; - - if (scheme === "bunker") { - let bunkerIdentifier = parsed.hostname || ""; - if (!bunkerIdentifier && parsed.pathname && parsed.pathname !== "/") { - bunkerIdentifier = parsed.pathname.replace(/^\/+/, ""); - } - - const normalizedIdentifier = normalizeNostrPubkey(bunkerIdentifier); - - if (remoteSignerParam) { - userPubkeyHint = normalizedIdentifier; - } else if (!remotePubkey) { - remotePubkey = normalizedIdentifier; - } - - if (!userPubkeyHint) { - const userParamCandidates = [ - "user", - "user_pubkey", - "user-pubkey", - "pubkey", - "npub", - "profile", - ]; - for (const key of userParamCandidates) { - const candidate = decodeParam(params.get(key)); - if (candidate) { - userPubkeyHint = normalizeNostrPubkey(candidate); - if (userPubkeyHint) { - break; - } - } - } - - if (!userPubkeyHint && normalizedIdentifier && normalizedIdentifier !== remotePubkey) { - userPubkeyHint = normalizedIdentifier; - } - } - } else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") { - clientPubkey = parsed.hostname || ""; - if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") { - clientPubkey = parsed.pathname.replace(/^\/+/, ""); - } - } - - const secretParam = decodeParam(params.get("secret")); - - return { - scheme, - type: scheme === "bunker" ? "remote" : "client", - remotePubkey: normalizeNostrPubkey(remotePubkey), - clientPubkey: normalizeNostrPubkey(clientPubkey), - relays, - secret: secretParam, - permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "", - metadata, - userPubkeyHint: normalizeNostrPubkey(userPubkeyHint), - }; -} - -function generateNip46Secret(length = 16) { - const size = Number.isFinite(length) && length > 0 ? Math.min(64, Math.max(8, Math.floor(length))) : 16; - try { - if (typeof crypto !== "undefined" && crypto?.getRandomValues) { - const bytes = new Uint8Array(size); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - } - } catch (error) { - // fall through to Math.random fallback - } - - let secret = ""; - for (let i = 0; i < size; i += 1) { - secret += Math.floor(Math.random() * 16).toString(16); - } - return secret; -} - -function sanitizeNip46Metadata(metadata) { - if (!metadata || typeof metadata !== "object") { - return {}; - } - - const normalized = {}; - for (const key of ["name", "url", "image"]) { - if (typeof metadata[key] === "string" && metadata[key].trim()) { - normalized[key] = metadata[key].trim(); - } - } - return normalized; -} - -function resolveNip44V2ConversationKeyGetter(tools) { - if (typeof tools?.nip44?.v2?.getConversationKey === "function") { - return tools.nip44.v2.getConversationKey.bind(tools.nip44.v2); - } - - if (typeof tools?.nip44?.v2?.utils?.getConversationKey === "function") { - return tools.nip44.v2.utils.getConversationKey.bind(tools.nip44.v2.utils); - } - - return null; -} - -function resolveLegacyNip44ConversationKeyGetter(tools) { - if (typeof tools?.nip44?.getConversationKey === "function") { - return tools.nip44.getConversationKey.bind(tools.nip44); - } - - if (typeof tools?.nip44?.utils?.getConversationKey === "function") { - return tools.nip44.utils.getConversationKey.bind(tools.nip44.utils); - } - - return null; -} - -let sharedTextDecoder = null; - -function decodeBytesToUtf8(value) { - if (value == null) { - return ""; - } - - if (typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function" && Buffer.isBuffer(value)) { - try { - return value.toString("utf8"); - } catch (error) { - return ""; - } - } - - let view = null; - - if (value instanceof Uint8Array) { - view = value; - } else if (typeof ArrayBuffer !== "undefined") { - if (value instanceof ArrayBuffer) { - view = new Uint8Array(value); - } else if (typeof ArrayBuffer.isView === "function" && ArrayBuffer.isView(value)) { - try { - view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } catch (error) { - view = null; - } - } - } - - if (!view && Array.isArray(value)) { - const bytes = []; - for (const entry of value) { - if (typeof entry === "number" && Number.isFinite(entry)) { - const normalized = entry & 0xff; - if (normalized < 0 || normalized > 255) { - return ""; - } - bytes.push(normalized); - } else { - return ""; - } - } - if (!bytes.length) { - return ""; - } - view = Uint8Array.from(bytes); - } - - if (!view || !view.length) { - return ""; - } - - if (!sharedTextDecoder && typeof TextDecoder === "function") { - try { - sharedTextDecoder = new TextDecoder(); - } catch (error) { - sharedTextDecoder = null; - } - } - - if (sharedTextDecoder) { - try { - return sharedTextDecoder.decode(view); - } catch (error) { - // fall through to manual decoding - } - } - - let result = ""; - for (let index = 0; index < view.length; index += 1) { - result += String.fromCharCode(view[index]); - } - return result; -} - -function normalizeNip46CiphertextPayload(payload) { - const visited = new Set(); - const candidates = new Set(); - - const addCandidate = (value) => { - if (typeof value !== "string") { - return; - } - const trimmed = value.trim(); - if (!trimmed) { - return; - } - candidates.add(trimmed); - }; - - const addCiphertextWithNonce = (ciphertext, nonce) => { - const normalizedCiphertext = typeof ciphertext === "string" ? ciphertext.trim() : ""; - const normalizedNonce = typeof nonce === "string" ? nonce.trim() : ""; - - if (!normalizedCiphertext) { - return; - } - - addCandidate(normalizedCiphertext); - - if (!normalizedNonce) { - return; - } - - const strippedNonce = normalizedNonce.replace(/^\?iv=/i, ""); - - addCandidate(`${normalizedCiphertext}\n${strippedNonce}`); - addCandidate(`${normalizedCiphertext}?iv=${strippedNonce}`); - - if (strippedNonce !== normalizedNonce) { - addCandidate(`${normalizedCiphertext}${normalizedNonce}`); - } - }; - - const extractScalarString = (value) => { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed : ""; - } - - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - - if (Array.isArray(value)) { - const decoded = decodeBytesToUtf8(value); - if (decoded) { - return decoded; - } - - if (value.length === 1) { - return extractScalarString(value[0]); - } - - return ""; - } - - if (!value || typeof value !== "object") { - return ""; - } - - const decoded = decodeBytesToUtf8(value); - if (decoded) { - return decoded; - } - - const preferredKeys = [ - "ciphertext", - "cipher_text", - "payload", - "content", - "result", - "value", - "data", - "nonce", - "iv", - "secret", - "message", - ]; - - for (const key of preferredKeys) { - if (typeof value[key] === "string" && value[key].trim()) { - return value[key].trim(); - } - } - - return ""; - }; - - const coerce = (value) => { - if (typeof value === "string") { - addCandidate(value); - - const trimmed = value.trim(); - if (!trimmed) { - return; - } - - const firstChar = trimmed.charAt(0); - const lastChar = trimmed.charAt(trimmed.length - 1); - if ( - (firstChar === "{" && lastChar === "}") || - (firstChar === "[" && lastChar === "]") - ) { - try { - coerce(JSON.parse(trimmed)); - } catch (error) { - // ignore JSON parse errors and fall through - } - } - return; - } - - if (Array.isArray(value)) { - const decoded = decodeBytesToUtf8(value); - if (decoded) { - addCandidate(decoded); - return; - } - - const scalarEntries = []; - for (const entry of value) { - const scalar = extractScalarString(entry); - if (scalar) { - scalarEntries.push(scalar); - } - } - - if (scalarEntries.length >= 2) { - for (let i = 0; i < scalarEntries.length; i += 1) { - for (let j = i + 1; j < scalarEntries.length; j += 1) { - addCiphertextWithNonce(scalarEntries[i], scalarEntries[j]); - addCiphertextWithNonce(scalarEntries[j], scalarEntries[i]); - } - } - } - - for (const entry of value) { - coerce(entry); - } - return; - } - - if (value && typeof value === "object") { - const decoded = decodeBytesToUtf8(value); - if (decoded) { - addCandidate(decoded); - return; - } - - if ( - typeof value.type === "string" && - value.type.toLowerCase() === "buffer" && - Array.isArray(value.data) - ) { - const bufferDecoded = decodeBytesToUtf8(value.data); - if (bufferDecoded) { - addCandidate(bufferDecoded); - return; - } - } - - if (visited.has(value)) { - return; - } - visited.add(value); - - const ciphertextCandidates = [ - typeof value.ciphertext === "string" ? value.ciphertext : "", - typeof value.cipher_text === "string" ? value.cipher_text : "", - typeof value.content === "string" ? value.content : "", - typeof value.payload === "string" ? value.payload : "", - typeof value.result === "string" ? value.result : "", - typeof value.value === "string" ? value.value : "", - typeof value.data === "string" ? value.data : "", - ]; - - const nonceCandidates = [ - typeof value.nonce === "string" ? value.nonce : "", - typeof value.iv === "string" ? value.iv : "", - ]; - - const ciphertext = ciphertextCandidates.find((candidate) => candidate && candidate.trim()); - const nonce = nonceCandidates.find((candidate) => candidate && candidate.trim()); - - if (ciphertext || nonce) { - addCiphertextWithNonce(ciphertext || "", nonce || ""); - } - - const nestedKeys = [ - "ciphertext", - "cipher_text", - "payload", - "result", - "value", - "data", - "content", - ]; - - for (const key of nestedKeys) { - if (key in value) { - coerce(value[key]); - } - } - - return; - } - - if (typeof value === "number" || typeof value === "boolean") { - addCandidate(String(value)); - } - }; - - coerce(payload); - - return Array.from(candidates); -} - -function resolveAvailableNip46Ciphers( - tools, - privateKey, - remotePubkey, - { preferredAlgorithm } = {}, -) { - if (!tools) { - return []; - } - - const normalizedPreferred = normalizeNip46EncryptionAlgorithm(preferredAlgorithm); - const available = []; - - const registerCipher = (algorithm, factory) => { - try { - const cipher = factory(); - if ( - cipher && - typeof cipher.encrypt === "function" && - typeof cipher.decrypt === "function" - ) { - available.push({ ...cipher, algorithm }); - } - } catch (error) { - // Ignore individual algorithm failures so other fallbacks can run. - devLogger.debug?.( - "[nostr] Skipping unavailable NIP-46 cipher algorithm", - algorithm, - error, - ); - } - }; - - const nip44v2GetConversationKey = resolveNip44V2ConversationKeyGetter(tools); - if ( - tools?.nip44?.v2?.encrypt && - tools?.nip44?.v2?.decrypt && - typeof nip44v2GetConversationKey === "function" - ) { - registerCipher("nip44.v2", () => { - const conversationKey = nip44v2GetConversationKey(privateKey, remotePubkey); - - if (!conversationKey) { - throw new Error("Failed to derive a nip44 conversation key for remote signing."); - } - - return { - encrypt: (plaintext, nonce) => - typeof nonce === "string" - ? tools.nip44.v2.encrypt(plaintext, conversationKey, nonce) - : tools.nip44.v2.encrypt(plaintext, conversationKey), - decrypt: (ciphertext) => tools.nip44.v2.decrypt(ciphertext, conversationKey), - }; - }); - } - - const nip44GetConversationKey = resolveLegacyNip44ConversationKeyGetter(tools); - - if ( - tools?.nip44?.encrypt && - tools?.nip44?.decrypt && - typeof nip44GetConversationKey === "function" - ) { - registerCipher("nip44", () => { - const conversationKey = nip44GetConversationKey(privateKey, remotePubkey); - - if (!conversationKey) { - throw new Error("Failed to derive a nip44 conversation key for remote signing."); - } - - return { - encrypt: (plaintext, nonce) => - typeof nonce === "string" - ? tools.nip44.encrypt(plaintext, conversationKey, nonce) - : tools.nip44.encrypt(plaintext, conversationKey), - decrypt: (ciphertext) => tools.nip44.decrypt(ciphertext, conversationKey), - }; - }); - } - - if (tools?.nip04?.encrypt && tools?.nip04?.decrypt) { - registerCipher("nip04", () => { - const privateKeyHex = typeof privateKey === "string" ? privateKey : ""; - const remotePubkeyHex = typeof remotePubkey === "string" ? remotePubkey : ""; - - if (!privateKeyHex || !remotePubkeyHex) { - throw new Error("Missing keys for nip04 encryption."); - } - - return { - encrypt: (plaintext) => - tools.nip04.encrypt(privateKeyHex, remotePubkeyHex, plaintext), - decrypt: (ciphertext) => - tools.nip04.decrypt(privateKeyHex, remotePubkeyHex, ciphertext), - }; - }); - } - - if (normalizedPreferred) { - const index = available.findIndex((entry) => entry.algorithm === normalizedPreferred); - if (index > 0) { - const [preferred] = available.splice(index, 1); - available.unshift(preferred); - } - } - - return available; -} - -export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) { - const available = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey, options); - - if (!available.length) { - throw new Error("Remote signer encryption helpers are unavailable."); - } - - return available[0]; -} - -async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools) { - throw new Error("NostrTools helpers are unavailable for NIP-46 payload decryption."); - } - - const ciphers = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey); - if (!ciphers.length) { - throw new Error("Remote signer encryption helpers are unavailable for NIP-46 payload decryption."); - } - const normalizedCandidates = normalizeNip46CiphertextPayload(ciphertext); - const candidates = normalizedCandidates.length - ? normalizedCandidates - : typeof ciphertext === "string" - ? [ciphertext] - : []; - - const tried = []; - for (const cipher of ciphers) { - for (const candidate of candidates) { - try { - const plaintext = cipher.decrypt(candidate); - return { plaintext, algorithm: cipher.algorithm }; - } catch (error) { - tried.push({ candidate, algorithm: cipher.algorithm, error }); - } - } - } - - const failure = new Error("Failed to decrypt NIP-46 payload with available candidates."); - failure.attempts = tried; - throw failure; -} - -function summarizeHexForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const normalized = value.trim().toLowerCase(); - if (normalized.length <= 12) { - return `${normalized} (len:${normalized.length})`; - } - return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; -} - -function summarizeSecretForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; - return `${visible} (len:${trimmed.length})`; -} - -function summarizeMetadataForLog(metadata) { - if (!metadata || typeof metadata !== "object") { - return []; - } - return Object.keys(metadata).slice(0, 12); -} - -function summarizeUrlForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - try { - const url = new URL(trimmed); - return { - origin: url.origin, - pathname: url.pathname, - hasQuery: Boolean(url.search), - hasHash: Boolean(url.hash), - length: trimmed.length, - }; - } catch (error) { - const length = trimmed.length; - if (length <= 64) { - return `${trimmed} (len:${length})`; - } - return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; - } -} - -function summarizePayloadPreviewForLog(value) { - if (typeof value !== "string") { - return { type: typeof value }; - } - - const trimmed = value.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - - return { - type: "string", - length: trimmed.length, - preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, - }; -} - -function summarizeRpcParamsForLog(method, params) { - if (!Array.isArray(params)) { - return []; - } - - return params.map((param, index) => { - if (typeof param === "string") { - if (method === "connect" && index === 1) { - return { index, secret: summarizeSecretForLog(param) }; - } - const trimmed = param.trim(); - if (!trimmed) { - return { index, type: "string", length: 0 }; - } - if (method === "sign_event") { - return { index, type: "string", length: trimmed.length }; - } - if (trimmed.length <= 64) { - return { index, value: trimmed, length: trimmed.length }; - } - return { - index, - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, - }; - } - - if (param && typeof param === "object") { - return { - index, - type: Array.isArray(param) ? "array" : "object", - keys: Object.keys(param).slice(0, 6), - }; - } - - return { index, type: typeof param }; - }); -} - -function summarizeRpcResultForLog(method, result) { - if (typeof result === "string") { - const trimmed = result.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - if (method === "connect") { - return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; - } - if (trimmed.length <= 96) { - return { type: "string", length: trimmed.length, value: trimmed }; - } - return { - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, - }; - } - - if (!result) { - return { type: typeof result }; - } - - if (typeof result === "object") { - return { - type: Array.isArray(result) ? "array" : "object", - keys: Object.keys(result).slice(0, 6), - }; - } - - return { type: typeof result }; -} - -function summarizeRelayPublishResultsForLog(results) { - if (!Array.isArray(results)) { - return []; - } - - return results.map((entry) => ({ - relay: entry?.relay || "", - success: Boolean(entry?.ok), - reason: entry?.error ? entry.error?.message || String(entry.error) : null, - })); -} - -async function attemptDecryptNip46HandshakePayload({ - clientPrivateKey, - candidateRemotePubkeys = [], - ciphertext, -}) { - const tried = []; - const seen = new Set(); - - for (const candidate of candidateRemotePubkeys) { - const normalized = normalizeNostrPubkey(candidate); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - devLogger.debug("[nostr] Handshake decrypt attempt", { - remotePubkey: summarizeHexForLog(normalized), - ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, - }); - try { - const result = await decryptNip46PayloadWithKeys( - clientPrivateKey, - normalized, - ciphertext, - ); - devLogger.debug("[nostr] Handshake decrypt succeeded", { - remotePubkey: summarizeHexForLog(normalized), - algorithm: result?.algorithm || null, - }); - return { ...result, remotePubkey: normalized }; - } catch (error) { - devLogger.debug("[nostr] Handshake decrypt failed for candidate", { - remotePubkey: summarizeHexForLog(normalized), - error: error?.message || error, - }); - tried.push({ remotePubkey: normalized, error }); - } - } - - const failure = new Error( - "Failed to decrypt remote signer handshake payload with provided keys.", - ); - failure.attempts = tried; - devLogger.warn("[nostr] Exhausted handshake decrypt candidates", { - attempts: tried.map((entry) => ({ - remotePubkey: summarizeHexForLog(entry.remotePubkey), - error: entry.error?.message || entry.error, - })), - }); - throw failure; -} - -class Nip46RpcClient { - constructor({ - nostrClient, - clientPrivateKey, - clientPublicKey, - remotePubkey, - relays, - encryption, - secret, - permissions, - metadata, - } = {}) { - this.nostrClient = nostrClient || null; - this.clientPrivateKey = - typeof clientPrivateKey === "string" && HEX64_REGEX.test(clientPrivateKey) - ? clientPrivateKey.toLowerCase() - : ""; - this.clientPublicKey = normalizeNostrPubkey(clientPublicKey); - this.remotePubkey = normalizeNostrPubkey(remotePubkey); - this.relays = resolveNip46Relays(relays, nostrClient?.relays || []); - this.encryptionAlgorithm = normalizeNip46EncryptionAlgorithm(encryption); - this.secret = typeof secret === "string" ? secret.trim() : ""; - this.permissions = typeof permissions === "string" ? permissions.trim() : ""; - this.metadata = metadata && typeof metadata === "object" ? { ...metadata } : {}; - - if (!this.clientPrivateKey) { - throw new Error("A NIP-46 client private key is required."); - } - - if (!this.clientPublicKey) { - const tools = getCachedNostrTools(); - if (!tools || typeof tools.getPublicKey !== "function") { - throw new Error("Public key derivation is unavailable."); - } - this.clientPublicKey = tools.getPublicKey(this.clientPrivateKey); - if (!this.clientPublicKey || !HEX64_REGEX.test(this.clientPublicKey)) { - throw new Error("Failed to derive a valid public key for the remote signer session."); - } - this.clientPublicKey = this.clientPublicKey.toLowerCase(); - } - - if (!this.remotePubkey) { - throw new Error("A remote signer pubkey is required."); - } - - this.pendingRequests = new Map(); - this.subscription = null; - this.destroyed = false; - this.cipher = null; - this.lastSeen = 0; - this.userPubkey = ""; - this.activeSignerCache = null; - } - - get pool() { - return this.nostrClient?.pool || null; - } - - async ensurePool() { - if (this.pool) { - devLogger.debug("[nostr] Remote signer using existing nostr pool"); - return this.pool; - } - - if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") { - throw new Error("Remote signer requires a nostr client pool."); - } - - await this.nostrClient.ensurePool(); - devLogger.debug("[nostr] Remote signer pool ensured"); - return this.pool; - } - - async ensureCipher() { - if (this.cipher) { - devLogger.debug("[nostr] Reusing cached remote signer cipher", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - algorithm: this.cipher?.algorithm || this.encryptionAlgorithm || null, - }); - return this.cipher; - } - - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools) { - throw new Error("NostrTools helpers are unavailable for remote signing."); - } - - const cipher = createNip46Cipher( - tools, - this.clientPrivateKey, - this.remotePubkey, - { preferredAlgorithm: this.encryptionAlgorithm }, - ); - this.cipher = cipher; - if (!this.encryptionAlgorithm && cipher?.algorithm) { - this.encryptionAlgorithm = cipher.algorithm; - } - devLogger.debug("[nostr] Created remote signer cipher", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - algorithm: cipher?.algorithm || null, - }); - return this.cipher; - } - - async encryptPayload(payload, context = {}) { - const { encrypt } = await this.ensureCipher(); - const serialized = typeof payload === "string" ? payload : JSON.stringify(payload); - const ciphertext = encrypt(serialized); - devLogger.debug("[nostr] Encrypted remote signer payload", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - method: context.method || null, - requestId: context.requestId || null, - payloadPreview: summarizePayloadPreviewForLog(serialized), - ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, - }); - return ciphertext; - } - - async decryptPayload(ciphertext, context = {}) { - const { decrypt } = await this.ensureCipher(); - try { - const plaintext = decrypt(ciphertext); - devLogger.debug("[nostr] Decrypted remote signer payload", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - method: context.method || null, - requestId: context.requestId || null, - ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, - plaintextPreview: summarizePayloadPreviewForLog(plaintext), - }); - return plaintext; - } catch (error) { - devLogger.warn("[nostr] Remote signer payload decryption failed", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - method: context.method || null, - requestId: context.requestId || null, - ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, - error: error?.message || String(error), - }); - throw error; - } - } - - async ensureSubscription() { - if (this.subscription) { - devLogger.debug("[nostr] Reusing remote signer subscription", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - relayCount: this.relays.length, - }); - return this.subscription; - } - - const pool = await this.ensurePool(); - const filters = [ - { - kinds: [NIP46_RPC_KIND], - authors: [this.remotePubkey], - "#p": [this.clientPublicKey], - }, - ]; - - const relays = this.relays.length ? this.relays : resolveNip46Relays([], this.nostrClient?.relays || []); - - const sub = pool.sub(relays, filters); - sub.on("event", (event) => { - this.handleEvent(event); - }); - sub.on("eose", () => { - // no-op; responses are push-based - }); - this.subscription = sub; - devLogger.debug("[nostr] Remote signer subscription created", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - relays, - filters, - }); - return sub; - } - - handleEvent(event) { - if (this.destroyed) { - return; - } - - if (!event || event.kind !== NIP46_RPC_KIND) { - return; - } - - if (typeof event.pubkey !== "string" || normalizeNostrPubkey(event.pubkey) !== this.remotePubkey) { - return; - } - - const tags = Array.isArray(event.tags) ? event.tags : []; - const targetsClient = tags.some( - (tag) => - Array.isArray(tag) && - tag[0] === "p" && - typeof tag[1] === "string" && - normalizeNostrPubkey(tag[1]) === this.clientPublicKey, - ); - - if (!targetsClient) { - return; - } - - const eventId = typeof event.id === "string" ? event.id : ""; - devLogger.debug("[nostr] Remote signer event received", { - eventId, - remotePubkey: summarizeHexForLog(event.pubkey || ""), - clientPubkey: summarizeHexForLog(this.clientPublicKey), - createdAt: Number.isFinite(event.created_at) ? event.created_at : null, - contentLength: typeof event.content === "string" ? event.content.length : 0, - }); - - Promise.resolve() - .then(() => - this.decryptPayload(event.content, { - requestId: eventId, - }), - ) - .then((payload) => { - let parsed; - try { - parsed = JSON.parse(payload); - } catch (error) { - devLogger.warn("[nostr] Remote signer returned malformed payload:", error); - return; - } - - if (!parsed || typeof parsed !== "object") { - return; - } - - const requestId = typeof parsed.id === "string" ? parsed.id : ""; - if (!requestId || !this.pendingRequests.has(requestId)) { - return; - } - - const pending = this.pendingRequests.get(requestId); - devLogger.debug("[nostr] Remote signer response parsed", { - eventId, - requestId, - remotePubkey: summarizeHexForLog(this.remotePubkey), - method: pending?.method || null, - resultSummary: summarizeRpcResultForLog(pending?.method || "", parsed.result), - errorSummary: summarizePayloadPreviewForLog(parsed.error ?? ""), - }); - this.pendingRequests.delete(requestId); - clearTimeout(pending.timeoutId); - - this.lastSeen = Date.now(); - - if ( - parsed.result === "auth_url" && - typeof parsed.error === "string" && - parsed.error.trim() - ) { - const authError = new Error("Remote signer requires additional authentication."); - authError.code = "auth-challenge"; - authError.authUrl = parsed.error.trim(); - pending.reject(authError); - return; - } - - if (typeof parsed.error === "string" && parsed.error.trim()) { - const err = new Error(parsed.error.trim()); - err.code = "nip46-error"; - pending.reject(err); - return; - } - - pending.resolve(parsed.result ?? null); - }) - .catch((error) => { - devLogger.warn("[nostr] Failed to decrypt NIP-46 payload:", error); - }); - } - - rejectAllPending(error) { - if (this.pendingRequests.size) { - devLogger.warn("[nostr] Rejecting pending remote signer RPC requests", { - count: this.pendingRequests.size, - remotePubkey: summarizeHexForLog(this.remotePubkey), - error: error?.message || String(error), - }); - } - for (const [id, pending] of this.pendingRequests.entries()) { - clearTimeout(pending.timeoutId); - try { - pending.reject(error); - } catch (rejectError) { - devLogger.warn("[nostr] Pending NIP-46 promise reject failed for", id, rejectError); - } - } - this.pendingRequests.clear(); - } - - async sendRpc(method, params = [], options = {}) { - if (this.destroyed) { - throw new Error("Remote signer session has been disposed."); - } - - const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 - ? options.timeoutMs - : NIP46_RESPONSE_TIMEOUT_MS; - const retries = Number.isFinite(options.retries) && options.retries >= 0 - ? options.retries - : NIP46_MAX_RETRIES; - - let lastError = null; - - devLogger.debug("[nostr] Remote signer RPC start", { - method, - remotePubkey: summarizeHexForLog(this.remotePubkey), - timeoutMs, - retries, - params: summarizeRpcParamsForLog(method, params), - }); - - for (let attempt = 0; attempt <= retries; attempt += 1) { - await this.ensureSubscription(); - - const requestId = createNip46RequestId(); - const message = { - id: requestId, - method, - params: Array.isArray(params) ? params : [], - }; - - devLogger.debug("[nostr] Remote signer RPC attempt prepared", { - method, - attempt: attempt + 1, - requestId, - remotePubkey: summarizeHexForLog(this.remotePubkey), - }); - - let event; - try { - const content = await this.encryptPayload(message, { - method, - requestId, - }); - event = signEventWithPrivateKey( - { - kind: NIP46_RPC_KIND, - pubkey: this.clientPublicKey, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.remotePubkey]], - content, - }, - this.clientPrivateKey, - ); - devLogger.debug("[nostr] Remote signer RPC event signed", { - method, - attempt: attempt + 1, - requestId, - relayCount: this.relays.length, - contentLength: typeof event.content === "string" ? event.content.length : 0, - }); - } catch (error) { - lastError = error; - devLogger.warn("[nostr] Remote signer RPC encryption failed", { - method, - attempt: attempt + 1, - requestId, - error: error?.message || String(error), - }); - break; - } - - const responsePromise = new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.pendingRequests.delete(requestId); - const timeoutError = new Error( - `Timed out waiting for remote signer response to ${method}.`, - ); - timeoutError.code = "nip46-timeout"; - devLogger.warn("[nostr] Remote signer RPC timed out", { - method, - requestId, - attempt: attempt + 1, - timeoutMs, - }); - reject(timeoutError); - }, timeoutMs); - - this.pendingRequests.set(requestId, { - resolve, - reject, - timeoutId, - method, - }); - devLogger.debug("[nostr] Remote signer RPC awaiting response", { - method, - requestId, - attempt: attempt + 1, - }); - }); - - try { - const publishResults = await publishEventToRelays( - await this.ensurePool(), - this.relays, - event, - { timeoutMs: NIP46_PUBLISH_TIMEOUT_MS }, - ); - assertAnyRelayAccepted(publishResults, { context: method }); - devLogger.debug("[nostr] Remote signer RPC published", { - method, - requestId, - attempt: attempt + 1, - publishResults: summarizeRelayPublishResultsForLog(publishResults), - }); - } catch (error) { - const pending = this.pendingRequests.get(requestId); - if (pending) { - clearTimeout(pending.timeoutId); - this.pendingRequests.delete(requestId); - } - lastError = error; - devLogger.warn("[nostr] Remote signer RPC publish failed", { - method, - requestId, - attempt: attempt + 1, - error: error?.message || String(error), - }); - continue; - } - - try { - const result = await responsePromise; - devLogger.debug("[nostr] Remote signer RPC response received", { - method, - requestId, - attempt: attempt + 1, - resultSummary: summarizeRpcResultForLog(method, result), - }); - return result; - } catch (error) { - lastError = error; - devLogger.warn("[nostr] Remote signer RPC attempt rejected", { - method, - requestId, - attempt: attempt + 1, - error: error?.message || String(error), - }); - if (error?.code === "auth-challenge") { - throw error; - } - } - } - - if (lastError) { - devLogger.warn("[nostr] Remote signer RPC exhausted", { - method, - remotePubkey: summarizeHexForLog(this.remotePubkey), - error: lastError?.message || String(lastError), - }); - throw lastError; - } - - devLogger.warn("[nostr] Remote signer RPC failed without explicit error", { - method, - remotePubkey: summarizeHexForLog(this.remotePubkey), - }); - throw new Error(`Remote signer request for ${method} failed.`); - } - - async connect({ permissions } = {}) { - const params = [this.remotePubkey]; - const requestedPermissions = permissions || this.permissions || ""; - - if (this.secret || requestedPermissions) { - params.push(this.secret || ""); - } - if (requestedPermissions) { - params.push(requestedPermissions); - } - - const result = await this.sendRpc("connect", params, { - timeoutMs: Math.max(NIP46_RESPONSE_TIMEOUT_MS, 12_000), - retries: 0, - }); - - if (this.secret) { - const normalizedResult = typeof result === "string" ? result.trim() : ""; - if (!normalizedResult || normalizedResult !== this.secret) { - const error = new Error("Remote signer secret mismatch. Rejecting connection."); - error.code = "nip46-secret-mismatch"; - throw error; - } - } - - devLogger.debug("[nostr] Remote signer connect RPC acknowledged", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - secretConfirmed: Boolean(this.secret), - resultSummary: summarizeRpcResultForLog("connect", result), - }); - return result; - } - - async getUserPubkey() { - const result = await this.sendRpc("get_public_key", [], { - timeoutMs: NIP46_RESPONSE_TIMEOUT_MS, - retries: 0, - }); - const pubkey = typeof result === "string" ? result.trim() : ""; - if (!pubkey) { - const error = new Error("Remote signer did not return a public key."); - error.code = "nip46-empty-pubkey"; - throw error; - } - this.userPubkey = normalizeNostrPubkey(pubkey); - devLogger.debug("[nostr] Remote signer get_public_key resolved", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - userPubkey: summarizeHexForLog(this.userPubkey), - }); - return this.userPubkey; - } - - async ping() { - try { - const result = await this.sendRpc("ping", [], { - timeoutMs: 5000, - retries: 0, - }); - const ok = typeof result === "string" && result.trim().toLowerCase() === "pong"; - devLogger.debug("[nostr] Remote signer ping result", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - ok, - summary: summarizeRpcResultForLog("ping", result), - }); - return ok; - } catch (error) { - devLogger.warn("[nostr] Remote signer ping failed", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - error: error?.message || String(error), - }); - return false; - } - } - - async signEvent(event, options = {}) { - if (!event || typeof event !== "object") { - throw new Error("A Nostr event is required for remote signing."); - } - - const unsigned = { - kind: event.kind, - created_at: event.created_at, - content: typeof event.content === "string" ? event.content : "", - tags: Array.isArray(event.tags) - ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) - : [], - pubkey: - typeof event.pubkey === "string" && event.pubkey.trim() - ? event.pubkey.trim() - : this.userPubkey, - }; - - const result = await this.sendRpc( - "sign_event", - [JSON.stringify(unsigned)], - { - timeoutMs: Number.isFinite(options.timeoutMs) - ? options.timeoutMs - : NIP46_SIGN_EVENT_TIMEOUT_MS, - retries: Number.isFinite(options.retries) ? options.retries : NIP46_MAX_RETRIES, - }, - ); - - if (!result) { - const error = new Error("Remote signer returned an empty response."); - error.code = "nip46-empty-response"; - throw error; - } - - if (typeof result === "object") { - devLogger.debug("[nostr] Remote signer sign_event returned object", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - keys: Object.keys(result).slice(0, 6), - }); - return result; - } - - try { - const parsed = JSON.parse(result); - devLogger.debug("[nostr] Remote signer sign_event returned JSON", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - keys: parsed && typeof parsed === "object" ? Object.keys(parsed).slice(0, 6) : [], - }); - return parsed; - } catch (error) { - const failure = new Error("Remote signer returned malformed signed event."); - failure.code = "nip46-invalid-response"; - failure.cause = error; - devLogger.warn("[nostr] Remote signer sign_event parse failed", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - error: error?.message || String(error), - }); - throw failure; - } - } - - getActiveSigner() { - if (!this.userPubkey) { - return null; - } - - if (!this.activeSignerCache) { - this.activeSignerCache = { - type: "nip46", - pubkey: this.userPubkey, - signEvent: (event) => this.signEvent(event), - }; - } - - return this.activeSignerCache; - } - - async destroy() { - if (this.destroyed) { - return; - } - this.destroyed = true; - - devLogger.debug("[nostr] Destroying remote signer client", { - remotePubkey: summarizeHexForLog(this.remotePubkey), - pendingRequests: this.pendingRequests.size, - hasSubscription: Boolean(this.subscription), - }); - - if (this.subscription && typeof this.subscription.unsub === "function") { - try { - this.subscription.unsub(); - } catch (error) { - devLogger.warn("[nostr] Failed to unsubscribe remote signer session:", error); - } - } - this.subscription = null; - - this.rejectAllPending(new Error("Remote signer session closed.")); - } -} - -const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; -const BECH32_CHARSET_MAP = (() => { - const map = new Map(); - for (let index = 0; index < BECH32_CHARSET.length; index += 1) { - map.set(BECH32_CHARSET[index], index); - } - return map; -})(); - -const BECH32_GENERATORS = [ - 0x3b6a57b2, - 0x26508e6d, - 0x1ea119fa, - 0x3d4233dd, - 0x2a1462b3, -]; - -function bech32Polymod(values) { - let chk = 1; - for (const value of values) { - const top = chk >>> 25; - chk = ((chk & 0x1ffffff) << 5) ^ value; - for (let bit = 0; bit < BECH32_GENERATORS.length; bit += 1) { - if ((top >>> bit) & 1) { - chk ^= BECH32_GENERATORS[bit]; - } - } - } - return chk; -} - -function bech32HrpExpand(hrp) { - const expansion = []; - for (let i = 0; i < hrp.length; i += 1) { - expansion.push(hrp.charCodeAt(i) >>> 5); - } - expansion.push(0); - for (let i = 0; i < hrp.length; i += 1) { - expansion.push(hrp.charCodeAt(i) & 31); - } - return expansion; -} - -function bech32VerifyChecksum(hrp, values) { - return bech32Polymod([...bech32HrpExpand(hrp), ...values]) === 1; -} - -function convertBits(data, fromBits, toBits) { - let acc = 0; - let bits = 0; - const maxValue = (1 << toBits) - 1; - const result = []; - - for (const value of data) { - if (value < 0 || value >>> fromBits !== 0) { - return null; - } - acc = (acc << fromBits) | value; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - result.push((acc >>> bits) & maxValue); - } - } - - if (bits > 0) { - if ((acc << (toBits - bits)) & maxValue) { - return null; - } - } - - return result; -} - -function decodeBech32Npub(value) { - if (typeof value !== "string") { - return ""; - } - - const hasMixedCase = value !== value.toLowerCase() && value !== value.toUpperCase(); - if (hasMixedCase) { - return ""; - } - - const normalized = value.toLowerCase(); - const separatorIndex = normalized.lastIndexOf("1"); - if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { - return ""; - } - - const hrp = normalized.slice(0, separatorIndex); - if (hrp !== "npub") { - return ""; - } - - const dataPart = normalized.slice(separatorIndex + 1); - const values = []; - for (let i = 0; i < dataPart.length; i += 1) { - const mapped = BECH32_CHARSET_MAP.get(dataPart[i]); - if (typeof mapped !== "number") { - return ""; - } - values.push(mapped); - } - - if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) { - return ""; - } - - const words = values.slice(0, -6); - const bytes = convertBits(words, 5, 8); - if (!bytes || bytes.length !== 32) { - return ""; - } - - return Array.from(bytes) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -function decodeNpubToHex(npub) { - if (typeof npub !== "string") { - return ""; - } - - const trimmed = npub.trim(); - if (!trimmed) { - return ""; - } - - if (/^[0-9a-f]{64}$/i.test(trimmed)) { - return trimmed.toLowerCase(); - } - - const lower = trimmed.toLowerCase(); - const hasNpubPrefix = lower.startsWith("npub1"); - if (!hasNpubPrefix) { - return ""; - } - - const warnableNpub = /^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/i.test( - trimmed - ); - - let tools = getCachedNostrTools(); - if (!tools || typeof tools?.nip19?.decode !== "function") { - const fallbackTools = readToolkitFromScope(); - if (fallbackTools) { - tools = fallbackTools; - } - } - - let decodeError = null; - if (tools?.nip19 && typeof tools.nip19.decode === "function") { - try { - const decoded = tools.nip19.decode(trimmed); - if (decoded?.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (error) { - decodeError = error; - } - } - - const manualDecoded = decodeBech32Npub(trimmed); - if (manualDecoded) { - return manualDecoded; - } - - if (isDevMode && warnableNpub) { - userLogger.warn( - `[nostr] Failed to decode npub: ${trimmed}`, - decodeError || new Error("invalid-npub"), - ); - } - - return ""; -} - -function encodeHexToNpub(pubkey) { - if (typeof pubkey !== "string") { - return ""; - } - - const normalized = pubkey.trim().toLowerCase(); - if (!normalized || !HEX64_REGEX.test(normalized)) { - return ""; - } - - let toolkit = getCachedNostrTools(); - if (!toolkit || typeof toolkit?.nip19?.npubEncode !== "function") { - const fallbackToolkit = readToolkitFromScope(); - if (fallbackToolkit?.nip19?.npubEncode) { - toolkit = fallbackToolkit; - } - } - - const encoder = toolkit?.nip19?.npubEncode; - if (typeof encoder !== "function") { - return ""; - } - - try { - return encoder(normalized); - } catch (error) { - if (isDevMode) { - devLogger.warn("[nostr] Failed to encode npub:", error); - } - return ""; - } -} const EXTENSION_MIME_MAP = { mp4: "video/mp4", @@ -5599,6 +3618,7 @@ export class NostrClient { permissions, metadata, encryption: handshakeAlgorithm, + signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), }); devLogger.debug("[nostr] Remote signer RPC client created", { @@ -5792,6 +3812,7 @@ export class NostrClient { secret: stored.secret, permissions: stored.permissions, metadata: stored.metadata, + signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), }); try { diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js new file mode 100644 index 00000000..66d160e5 --- /dev/null +++ b/js/nostr/nip46Client.js @@ -0,0 +1,1983 @@ +// js/nostr/nip46Client.js + +import { isDevMode } from "../config.js"; +import { + DEFAULT_RELAY_URLS, + ensureNostrTools, + getCachedNostrTools, + readToolkitFromScope, +} from "./toolkit.js"; +import { + publishEventToRelays as defaultPublishEventToRelays, + assertAnyRelayAccepted as defaultAssertAnyRelayAccepted, +} from "../nostrPublish.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +export const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +export const NIP46_RPC_KIND = 24_133; +export const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +export const NIP46_PUBLISH_TIMEOUT_MS = 8_000; +export const NIP46_RESPONSE_TIMEOUT_MS = 15_000; +export const NIP46_SIGN_EVENT_TIMEOUT_MS = 20_000; +export const NIP46_MAX_RETRIES = 1; +export const NIP46_HANDSHAKE_TIMEOUT_MS = 60_000; +export const NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS = 5; +export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([ + "nip44.v2", + "nip44", + "nip04", +]); + +export function getNip46Storage() { + if (typeof localStorage !== "undefined" && localStorage) { + return localStorage; + } + + if (typeof globalThis !== "undefined" && globalThis?.localStorage) { + return globalThis.localStorage; + } + + return null; +} + +export function sanitizeRelayList(list) { + const seen = new Set(); + const sanitized = []; + if (!Array.isArray(list)) { + return sanitized; + } + + list.forEach((value) => { + if (typeof value !== "string") { + return; + } + const trimmed = value.trim(); + if (!trimmed) { + return; + } + if (!/^wss?:\/\//i.test(trimmed)) { + return; + } + if (/\s/.test(trimmed)) { + return; + } + + let normalized = trimmed.replace(/\/+$/, ""); + try { + const parsed = new URL(trimmed); + if (!parsed.hostname) { + return; + } + const pathname = parsed.pathname.replace(/\/+$/, ""); + normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`; + } catch (error) { + // Ignore URL parsing failures and fall back to the trimmed string. + } + + if (seen.has(normalized)) { + return; + } + seen.add(normalized); + sanitized.push(normalized); + }); + + return sanitized; +} + +export function sanitizeStoredNip46Session(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const version = Number(candidate.version) || 0; + if (version !== 1) { + return null; + } + + const clientPrivateKey = + typeof candidate.clientPrivateKey === "string" && HEX64_REGEX.test(candidate.clientPrivateKey) + ? candidate.clientPrivateKey.toLowerCase() + : ""; + const clientPublicKey = + typeof candidate.clientPublicKey === "string" && candidate.clientPublicKey.trim() + ? candidate.clientPublicKey.trim().toLowerCase() + : ""; + const remotePubkey = + typeof candidate.remotePubkey === "string" && candidate.remotePubkey.trim() + ? candidate.remotePubkey.trim().toLowerCase() + : ""; + + if (!clientPrivateKey || !remotePubkey) { + return null; + } + + const relays = Array.isArray(candidate.relays) + ? candidate.relays + .map((relay) => (typeof relay === "string" ? relay.trim() : "")) + .filter(Boolean) + : []; + + const metadata = + candidate.metadata && typeof candidate.metadata === "object" + ? { + name: + typeof candidate.metadata.name === "string" + ? candidate.metadata.name.trim() + : "", + url: + typeof candidate.metadata.url === "string" + ? candidate.metadata.url.trim() + : "", + image: + typeof candidate.metadata.image === "string" + ? candidate.metadata.image.trim() + : "", + } + : {}; + + return { + version: 1, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + encryption: normalizeNip46EncryptionAlgorithm( + typeof candidate.encryption === "string" + ? candidate.encryption + : typeof candidate.algorithm === "string" + ? candidate.algorithm + : "", + ), + secret: + typeof candidate.secret === "string" && candidate.secret.trim() + ? candidate.secret.trim() + : "", + permissions: + typeof candidate.permissions === "string" && candidate.permissions.trim() + ? candidate.permissions.trim() + : "", + metadata, + userPubkey: + typeof candidate.userPubkey === "string" && candidate.userPubkey.trim() + ? candidate.userPubkey.trim().toLowerCase() + : "", + lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) + ? candidate.lastConnectedAt + : Date.now(), + }; +} + +export function readStoredNip46Session() { + const storage = getNip46Storage(); + if (!storage) { + return null; + } + + let raw = null; + try { + raw = storage.getItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + return null; + } + + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + return sanitizeStoredNip46Session(parsed); + } catch (error) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt NIP-46 session entry:", + cleanupError, + ); + } + return null; + } +} + +export function writeStoredNip46Session(payload) { + const storage = getNip46Storage(); + if (!storage) { + return; + } + + const normalized = sanitizeStoredNip46Session(payload); + if (!normalized) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup failures + } + return; + } + + try { + storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized)); + } catch (error) { + // ignore persistence failures + } +} + +export function clearStoredNip46Session() { + const storage = getNip46Storage(); + if (!storage) { + return; + } + + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } +} + +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_CHARSET_MAP = (() => { + const map = new Map(); + for (let index = 0; index < BECH32_CHARSET.length; index += 1) { + map.set(BECH32_CHARSET[index], index); + } + return map; +})(); + +function bech32Polymod(values) { + const GENERATORS = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, + ]; + + let chk = 1; + for (const value of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < GENERATORS.length; i += 1) { + if ((top >> i) & 1) { + chk ^= GENERATORS[i]; + } + } + } + return chk; +} + +function bech32HrpExpand(hrp) { + const result = []; + for (let i = 0; i < hrp.length; i += 1) { + result.push(hrp.charCodeAt(i) >> 5); + } + result.push(0); + for (let i = 0; i < hrp.length; i += 1) { + result.push(hrp.charCodeAt(i) & 31); + } + return result; +} + +function bech32VerifyChecksum(hrp, data) { + return bech32Polymod([...bech32HrpExpand(hrp), ...data]) === 1; +} + +function convertBits(data, fromBits, toBits) { + let accumulator = 0; + let bits = 0; + const result = []; + const maxValue = (1 << toBits) - 1; + const maxAccumulator = (1 << (fromBits + toBits - 1)) - 1; + + for (const value of data) { + if (value < 0 || (value >> fromBits) !== 0) { + return null; + } + accumulator = ((accumulator << fromBits) | value) & maxAccumulator; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((accumulator >> bits) & maxValue); + } + } + + if (bits > 0) { + result.push((accumulator << (toBits - bits)) & maxValue); + } + + return result; +} + +function decodeBech32Npub(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const normalized = trimmed.toLowerCase(); + const separatorIndex = normalized.lastIndexOf("1"); + if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { + return ""; + } + + const hrp = normalized.slice(0, separatorIndex); + if (hrp !== "npub") { + return ""; + } + + const dataPart = normalized.slice(separatorIndex + 1); + const values = []; + for (let i = 0; i < dataPart.length; i += 1) { + const mapped = BECH32_CHARSET_MAP.get(dataPart[i]); + if (typeof mapped !== "number") { + return ""; + } + values.push(mapped); + } + + if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) { + return ""; + } + + const words = values.slice(0, -6); + const bytes = convertBits(words, 5, 8); + if (!bytes || bytes.length !== 32) { + return ""; + } + + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function decodeNpubToHex(npub) { + if (typeof npub !== "string") { + return ""; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return ""; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const lower = trimmed.toLowerCase(); + const hasNpubPrefix = lower.startsWith("npub1"); + if (!hasNpubPrefix) { + return ""; + } + + const warnableNpub = /^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/i.test( + trimmed, + ); + + let tools = getCachedNostrTools(); + if (!tools || typeof tools?.nip19?.decode !== "function") { + const fallbackTools = readToolkitFromScope(); + if (fallbackTools) { + tools = fallbackTools; + } + } + + let decodeError = null; + if (tools?.nip19 && typeof tools.nip19.decode === "function") { + try { + const decoded = tools.nip19.decode(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } catch (error) { + decodeError = error; + } + } + + const manualDecoded = decodeBech32Npub(trimmed); + if (manualDecoded) { + return manualDecoded; + } + + if (isDevMode && warnableNpub) { + userLogger.warn( + `[nostr] Failed to decode npub: ${trimmed}`, + decodeError || new Error("invalid-npub"), + ); + } + + return ""; +} + +export function encodeHexToNpub(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const normalized = pubkey.trim().toLowerCase(); + if (!normalized || !HEX64_REGEX.test(normalized)) { + return ""; + } + + let toolkit = getCachedNostrTools(); + if (!toolkit || typeof toolkit?.nip19?.npubEncode !== "function") { + const fallbackToolkit = readToolkitFromScope(); + if (fallbackToolkit?.nip19?.npubEncode) { + toolkit = fallbackToolkit; + } + } + + const encoder = toolkit?.nip19?.npubEncode; + if (typeof encoder !== "function") { + return ""; + } + + try { + return encoder(normalized); + } catch (error) { + if (isDevMode) { + devLogger.warn("[nostr] Failed to encode npub", error); + } + return ""; + } +} + +export function normalizeNostrPubkey(candidate) { + if (typeof candidate !== "string") { + return ""; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const decoded = decodeNpubToHex(trimmed); + if (decoded && HEX64_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + + return trimmed.toLowerCase(); +} + +export function parseNip46ConnectionString(uri) { + const value = typeof uri === "string" ? uri.trim() : ""; + if (!value) { + return null; + } + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + return null; + } + + const decodeParam = (raw) => { + if (typeof raw !== "string") { + return ""; + } + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + try { + return decodeURIComponent(trimmed); + } catch (error) { + return trimmed; + } + }; + + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + const params = parsed.searchParams || new URLSearchParams(); + + const relays = params + .getAll("relay") + .map((relay) => decodeParam(relay)) + .filter(Boolean); + + const permissionsParam = + decodeParam(params.get("perms")) || decodeParam(params.get("permissions")) || ""; + const metadata = { + name: "", + url: "", + image: "", + }; + + for (const key of ["name", "url", "image"]) { + const decoded = decodeParam(params.get(key)); + if (decoded) { + metadata[key] = decoded; + } + } + + const remoteSignerParamKeys = [ + "remote-signer-key", + "remote_signer_key", + "remotesignerkey", + "remoteSignerKey", + "remote-signer-pubkey", + "remote_signer_pubkey", + "remoteSignerPubkey", + ]; + + let remoteSignerParam = ""; + for (const key of remoteSignerParamKeys) { + const candidate = decodeParam(params.get(key)); + if (candidate) { + remoteSignerParam = candidate; + break; + } + } + + const remoteFallbackParam = + decodeParam(params.get("remote")) || + decodeParam(params.get("remotePubkey")) || + decodeParam(params.get("signer")) || + ""; + + let remotePubkey = remoteSignerParam || remoteFallbackParam || ""; + let clientPubkey = ""; + let userPubkeyHint = ""; + + if (scheme === "bunker") { + let bunkerIdentifier = parsed.hostname || ""; + if (!bunkerIdentifier && parsed.pathname && parsed.pathname !== "/") { + bunkerIdentifier = parsed.pathname.replace(/^\/+/, ""); + } + + const normalizedIdentifier = normalizeNostrPubkey(bunkerIdentifier); + + if (remoteSignerParam) { + userPubkeyHint = normalizedIdentifier; + } else if (!remotePubkey) { + remotePubkey = normalizedIdentifier; + } + + if (!userPubkeyHint) { + const userParamCandidates = [ + "user", + "user_pubkey", + "user-pubkey", + "pubkey", + "npub", + "profile", + ]; + for (const key of userParamCandidates) { + const candidate = decodeParam(params.get(key)); + if (candidate) { + userPubkeyHint = normalizeNostrPubkey(candidate); + if (userPubkeyHint) { + break; + } + } + } + + if (!userPubkeyHint && normalizedIdentifier && normalizedIdentifier !== remotePubkey) { + userPubkeyHint = normalizedIdentifier; + } + } + } else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") { + clientPubkey = parsed.hostname || ""; + if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") { + clientPubkey = parsed.pathname.replace(/^\/+/, ""); + } + } + + const secretParam = decodeParam(params.get("secret")); + + return { + scheme, + type: scheme === "bunker" ? "remote" : "client", + remotePubkey: normalizeNostrPubkey(remotePubkey), + clientPubkey: normalizeNostrPubkey(clientPubkey), + relays, + secret: secretParam, + permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "", + metadata, + userPubkeyHint: normalizeNostrPubkey(userPubkeyHint), + }; +} + +export function generateNip46Secret(length = 16) { + const size = Number.isFinite(length) && length > 0 ? Math.min(64, Math.max(8, Math.floor(length))) : 16; + try { + if (typeof crypto !== "undefined" && crypto?.getRandomValues) { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + } catch (error) { + // fall through to Math.random fallback + } + + let secret = ""; + for (let i = 0; i < size; i += 1) { + secret += Math.floor(Math.random() * 16).toString(16); + } + return secret; +} + +export function sanitizeNip46Metadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return {}; + } + + const normalized = {}; + for (const key of ["name", "url", "image"]) { + if (typeof metadata[key] === "string" && metadata[key].trim()) { + normalized[key] = metadata[key].trim(); + } + } + return normalized; +} + +export function normalizeNip46EncryptionAlgorithm(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return ""; + } + + if (NIP46_ENCRYPTION_ALGORITHMS.includes(trimmed)) { + return trimmed; + } + + const aliasMap = new Map([ + ["nip44v2", "nip44.v2"], + ["nip44-v2", "nip44.v2"], + ["nip44_v2", "nip44.v2"], + ["nip44-v1", "nip44"], + ["nip44v1", "nip44"], + ["nip44_v1", "nip44"], + ["nip-44", "nip44"], + ["nip-04", "nip04"], + ["nip04.v1", "nip04"], + ]); + + if (aliasMap.has(trimmed)) { + return aliasMap.get(trimmed) || ""; + } + + return ""; +} + +export function resolveNip46Relays(relays, fallbackRelays = []) { + const primary = sanitizeRelayList(Array.isArray(relays) ? relays : []); + if (primary.length) { + return primary; + } + + const fallback = sanitizeRelayList(Array.isArray(fallbackRelays) ? fallbackRelays : []); + if (fallback.length) { + return fallback; + } + + return Array.from(DEFAULT_RELAY_URLS); +} + +function resolveNip44V2ConversationKeyGetter(tools) { + if (typeof tools?.nip44?.v2?.getConversationKey === "function") { + return tools.nip44.v2.getConversationKey.bind(tools.nip44.v2); + } + + if (typeof tools?.nip44?.v2?.utils?.getConversationKey === "function") { + return tools.nip44.v2.utils.getConversationKey.bind(tools.nip44.v2.utils); + } + + return null; +} + +function resolveLegacyNip44ConversationKeyGetter(tools) { + if (typeof tools?.nip44?.getConversationKey === "function") { + return tools.nip44.getConversationKey.bind(tools.nip44); + } + + if (typeof tools?.nip44?.utils?.getConversationKey === "function") { + return tools.nip44.utils.getConversationKey.bind(tools.nip44.utils); + } + + return null; +} + +let sharedTextDecoder = null; + +function decodeBytesToUtf8(value) { + if (value == null) { + return ""; + } + + if (typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function" && Buffer.isBuffer(value)) { + try { + return value.toString("utf8"); + } catch (error) { + return ""; + } + } + + let view = null; + + if (value instanceof Uint8Array) { + view = value; + } else if (typeof ArrayBuffer !== "undefined") { + if (value instanceof ArrayBuffer) { + view = new Uint8Array(value); + } else if (typeof ArrayBuffer.isView === "function" && ArrayBuffer.isView(value)) { + try { + view = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } catch (error) { + view = null; + } + } + } + + if (!view && Array.isArray(value)) { + const bytes = []; + for (const entry of value) { + if (typeof entry === "number" && Number.isFinite(entry)) { + const normalized = entry & 0xff; + if (normalized < 0 || normalized > 255) { + return ""; + } + bytes.push(normalized); + } else { + return ""; + } + } + if (!bytes.length) { + return ""; + } + view = Uint8Array.from(bytes); + } + + if (!view || !view.length) { + return ""; + } + + if (!sharedTextDecoder && typeof TextDecoder === "function") { + try { + sharedTextDecoder = new TextDecoder(); + } catch (error) { + sharedTextDecoder = null; + } + } + + if (sharedTextDecoder) { + try { + return sharedTextDecoder.decode(view); + } catch (error) { + // fall through to manual decoding + } + } + + let result = ""; + for (let index = 0; index < view.length; index += 1) { + result += String.fromCharCode(view[index]); + } + return result; +} + +export function normalizeNip46CiphertextPayload(payload) { + const visited = new Set(); + const candidates = new Set(); + + const addCandidate = (value) => { + if (typeof value !== "string") { + return; + } + const trimmed = value.trim(); + if (!trimmed) { + return; + } + candidates.add(trimmed); + }; + + const addCiphertextWithNonce = (ciphertext, nonce) => { + const normalizedCiphertext = typeof ciphertext === "string" ? ciphertext.trim() : ""; + const normalizedNonce = typeof nonce === "string" ? nonce.trim() : ""; + + if (!normalizedCiphertext) { + return; + } + + addCandidate(normalizedCiphertext); + + if (!normalizedNonce) { + return; + } + + const strippedNonce = normalizedNonce.replace(/^\?iv=/i, ""); + + addCandidate(`${normalizedCiphertext}\n${strippedNonce}`); + addCandidate(`${normalizedCiphertext}?iv=${strippedNonce}`); + + if (strippedNonce !== normalizedNonce) { + addCandidate(`${normalizedCiphertext}${normalizedNonce}`); + } + }; + + const coerce = (value) => { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed) { + addCandidate(trimmed); + } + return; + } + + if (typeof value === "number" || typeof value === "boolean") { + addCandidate(String(value)); + return; + } + + if (Array.isArray(value)) { + const scalarEntries = []; + for (const entry of value) { + if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { + const candidate = String(entry).trim(); + if (candidate) { + scalarEntries.push(candidate); + addCandidate(candidate); + } + } else if (entry && typeof entry === "object") { + coerce(entry); + } + } + + if (scalarEntries.length >= 2) { + for (let i = 0; i < scalarEntries.length; i += 1) { + for (let j = i + 1; j < scalarEntries.length; j += 1) { + addCiphertextWithNonce(scalarEntries[i], scalarEntries[j]); + addCiphertextWithNonce(scalarEntries[j], scalarEntries[i]); + } + } + } + + for (const entry of value) { + coerce(entry); + } + return; + } + + if (value && typeof value === "object") { + const decoded = decodeBytesToUtf8(value); + if (decoded) { + addCandidate(decoded); + return; + } + + if ( + typeof value.type === "string" && + value.type.toLowerCase() === "buffer" && + Array.isArray(value.data) + ) { + const bufferDecoded = decodeBytesToUtf8(value.data); + if (bufferDecoded) { + addCandidate(bufferDecoded); + return; + } + } + + if (visited.has(value)) { + return; + } + visited.add(value); + + const ciphertextCandidates = [ + typeof value.ciphertext === "string" ? value.ciphertext : "", + typeof value.cipher_text === "string" ? value.cipher_text : "", + typeof value.content === "string" ? value.content : "", + typeof value.payload === "string" ? value.payload : "", + typeof value.result === "string" ? value.result : "", + typeof value.value === "string" ? value.value : "", + typeof value.data === "string" ? value.data : "", + ]; + + const nonceCandidates = [ + typeof value.nonce === "string" ? value.nonce : "", + typeof value.iv === "string" ? value.iv : "", + ]; + + const ciphertext = ciphertextCandidates.find((candidate) => candidate && candidate.trim()); + const nonce = nonceCandidates.find((candidate) => candidate && candidate.trim()); + + if (ciphertext || nonce) { + addCiphertextWithNonce(ciphertext || "", nonce || ""); + } + + const nestedKeys = [ + "ciphertext", + "cipher_text", + "payload", + "result", + "value", + "data", + "content", + ]; + + for (const key of nestedKeys) { + if (key in value) { + coerce(value[key]); + } + } + + return; + } + }; + + coerce(payload); + + return Array.from(candidates); +} + +function resolveAvailableNip46Ciphers( + tools, + privateKey, + remotePubkey, + { preferredAlgorithm } = {}, +) { + if (!tools) { + return []; + } + + const normalizedPreferred = normalizeNip46EncryptionAlgorithm(preferredAlgorithm); + const available = []; + + const registerCipher = (algorithm, factory) => { + try { + const cipher = factory(); + if ( + cipher && + typeof cipher.encrypt === "function" && + typeof cipher.decrypt === "function" + ) { + available.push({ ...cipher, algorithm }); + } + } catch (error) { + // Ignore individual algorithm failures so other fallbacks can run. + devLogger.debug?.( + "[nostr] Skipping unavailable NIP-46 cipher algorithm", + algorithm, + error, + ); + } + }; + + const nip44v2GetConversationKey = resolveNip44V2ConversationKeyGetter(tools); + if ( + tools?.nip44?.v2?.encrypt && + tools?.nip44?.v2?.decrypt && + typeof nip44v2GetConversationKey === "function" + ) { + registerCipher("nip44.v2", () => { + const conversationKey = nip44v2GetConversationKey(privateKey, remotePubkey); + + if (!conversationKey) { + throw new Error("Failed to derive a nip44 conversation key for remote signing."); + } + + return { + encrypt: (plaintext, nonce) => + typeof nonce === "string" + ? tools.nip44.v2.encrypt(plaintext, conversationKey, nonce) + : tools.nip44.v2.encrypt(plaintext, conversationKey), + decrypt: (ciphertext) => tools.nip44.v2.decrypt(ciphertext, conversationKey), + }; + }); + } + + const nip44GetConversationKey = resolveLegacyNip44ConversationKeyGetter(tools); + + if ( + tools?.nip44?.encrypt && + tools?.nip44?.decrypt && + typeof nip44GetConversationKey === "function" + ) { + registerCipher("nip44", () => { + const conversationKey = nip44GetConversationKey(privateKey, remotePubkey); + + if (!conversationKey) { + throw new Error("Failed to derive a nip44 conversation key for remote signing."); + } + + return { + encrypt: (plaintext, nonce) => + typeof nonce === "string" + ? tools.nip44.encrypt(plaintext, conversationKey, nonce) + : tools.nip44.encrypt(plaintext, conversationKey), + decrypt: (ciphertext) => tools.nip44.decrypt(ciphertext, conversationKey), + }; + }); + } + + if (tools?.nip04?.encrypt && tools?.nip04?.decrypt) { + registerCipher("nip04", () => { + const privateKeyHex = typeof privateKey === "string" ? privateKey : ""; + const remotePubkeyHex = typeof remotePubkey === "string" ? remotePubkey : ""; + + if (!privateKeyHex || !remotePubkeyHex) { + throw new Error("Missing keys for nip04 encryption."); + } + + return { + encrypt: (plaintext) => + tools.nip04.encrypt(privateKeyHex, remotePubkeyHex, plaintext), + decrypt: (ciphertext) => + tools.nip04.decrypt(privateKeyHex, remotePubkeyHex, ciphertext), + }; + }); + } + + if (normalizedPreferred) { + const index = available.findIndex((entry) => entry.algorithm === normalizedPreferred); + if (index > 0) { + const [preferred] = available.splice(index, 1); + available.unshift(preferred); + } + } + + return available; +} + +export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) { + const available = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey, options); + + if (!available.length) { + throw new Error("Remote signer encryption helpers are unavailable."); + } + + return available[0]; +} + +export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + throw new Error("NostrTools helpers are unavailable for NIP-46 payload decryption."); + } + + const ciphers = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey); + if (!ciphers.length) { + throw new Error("Remote signer encryption helpers are unavailable for NIP-46 payload decryption."); + } + const normalizedCandidates = normalizeNip46CiphertextPayload(ciphertext); + const candidates = normalizedCandidates.length + ? normalizedCandidates + : typeof ciphertext === "string" + ? [ciphertext] + : []; + + const tried = []; + for (const cipher of ciphers) { + for (const candidate of candidates) { + try { + const plaintext = cipher.decrypt(candidate); + return { plaintext, algorithm: cipher.algorithm }; + } catch (error) { + tried.push({ candidate, algorithm: cipher.algorithm, error }); + } + } + } + + const failure = new Error("Failed to decrypt NIP-46 payload with available candidates."); + failure.attempts = tried; + throw failure; +} + +export function summarizeHexForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const normalized = value.trim().toLowerCase(); + if (normalized.length <= 12) { + return `${normalized} (len:${normalized.length})`; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; +} + +export function summarizeSecretForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; + return `${visible} (len:${trimmed.length})`; +} + +export function summarizeMetadataForLog(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + return Object.keys(metadata).slice(0, 12); +} + +export function summarizeUrlForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + return { + origin: url.origin, + pathname: url.pathname, + hasQuery: Boolean(url.search), + hasHash: Boolean(url.hash), + length: trimmed.length, + }; + } catch (error) { + const length = trimmed.length; + if (length <= 64) { + return `${trimmed} (len:${length})`; + } + return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; + } +} + +export function summarizePayloadPreviewForLog(value) { + if (typeof value !== "string") { + return { type: typeof value }; + } + + const trimmed = value.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + + return { + type: "string", + length: trimmed.length, + preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, + }; +} + +export function summarizeRpcParamsForLog(method, params) { + if (!Array.isArray(params)) { + return []; + } + + return params.map((param, index) => { + if (typeof param === "string") { + if (method === "connect" && index === 1) { + return { index, secret: summarizeSecretForLog(param) }; + } + const trimmed = param.trim(); + if (!trimmed) { + return { index, type: "string", length: 0 }; + } + if (method === "sign_event") { + return { index, type: "string", length: trimmed.length }; + } + if (trimmed.length <= 64) { + return { index, value: trimmed, length: trimmed.length }; + } + return { + index, + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, + }; + } + + if (param && typeof param === "object") { + return { + index, + type: Array.isArray(param) ? "array" : "object", + keys: Object.keys(param).slice(0, 6), + }; + } + + return { index, type: typeof param }; + }); +} + +export function summarizeRpcResultForLog(method, result) { + if (typeof result === "string") { + const trimmed = result.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + if (method === "connect") { + return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; + } + if (trimmed.length <= 96) { + return { type: "string", length: trimmed.length, value: trimmed }; + } + return { + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, + }; + } + + if (!result) { + return { type: typeof result }; + } + + if (typeof result === "object") { + return { + type: Array.isArray(result) ? "array" : "object", + keys: Object.keys(result).slice(0, 6), + }; + } + + return { type: typeof result }; +} + +export function summarizeRelayPublishResultsForLog(results) { + if (!Array.isArray(results)) { + return []; + } + + return results.map((entry) => ({ + relay: entry?.relay || "", + success: Boolean(entry?.ok), + reason: entry?.error ? entry.error?.message || String(entry.error) : null, + })); +} + +export function createNip46RequestId() { + try { + if (typeof crypto !== "undefined" && crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + } catch (error) { + // fall through to timestamp-based id + } + + const timestamp = Date.now().toString(16); + const entropy = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0"); + return `${timestamp}${entropy}`; +} + +export async function attemptDecryptNip46HandshakePayload({ + clientPrivateKey, + candidateRemotePubkeys = [], + ciphertext, +}) { + const tried = []; + const seen = new Set(); + + for (const candidate of candidateRemotePubkeys) { + const normalized = normalizeNostrPubkey(candidate); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + devLogger.debug("[nostr] Handshake decrypt attempt", { + remotePubkey: summarizeHexForLog(normalized), + ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, + }); + try { + const result = await decryptNip46PayloadWithKeys( + clientPrivateKey, + normalized, + ciphertext, + ); + devLogger.debug("[nostr] Handshake decrypt succeeded", { + remotePubkey: summarizeHexForLog(normalized), + algorithm: result?.algorithm || null, + }); + return { ...result, remotePubkey: normalized }; + } catch (error) { + devLogger.debug("[nostr] Handshake decrypt failed for candidate", { + remotePubkey: summarizeHexForLog(normalized), + error: error?.message || error, + }); + tried.push({ remotePubkey: normalized, error }); + } + } + + const failure = new Error( + "Failed to decrypt remote signer handshake payload with provided keys.", + ); + failure.attempts = tried; + devLogger.warn("[nostr] Exhausted handshake decrypt candidates", { + attempts: tried.map((entry) => ({ + remotePubkey: summarizeHexForLog(entry.remotePubkey), + error: entry.error?.message || entry.error, + })), + }); + throw failure; +} +export class Nip46RpcClient { + constructor({ + nostrClient, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + encryption, + secret, + permissions, + metadata, + signEvent, + publishEventToRelays = defaultPublishEventToRelays, + assertAnyRelayAccepted = defaultAssertAnyRelayAccepted, + } = {}) { + this.nostrClient = nostrClient || null; + this.clientPrivateKey = + typeof clientPrivateKey === "string" && HEX64_REGEX.test(clientPrivateKey) + ? clientPrivateKey.toLowerCase() + : ""; + this.clientPublicKey = normalizeNostrPubkey(clientPublicKey); + this.remotePubkey = normalizeNostrPubkey(remotePubkey); + this.relays = resolveNip46Relays(relays, nostrClient?.relays || []); + this.encryptionAlgorithm = normalizeNip46EncryptionAlgorithm(encryption); + this.secret = typeof secret === "string" ? secret.trim() : ""; + this.permissions = typeof permissions === "string" ? permissions.trim() : ""; + this.metadata = metadata && typeof metadata === "object" ? { ...metadata } : {}; + this.signEventWithKey = typeof signEvent === "function" ? signEvent : null; + this.publishEventToRelays = + typeof publishEventToRelays === "function" + ? publishEventToRelays + : defaultPublishEventToRelays; + this.assertAnyRelayAccepted = + typeof assertAnyRelayAccepted === "function" + ? assertAnyRelayAccepted + : defaultAssertAnyRelayAccepted; + + if (!this.clientPrivateKey) { + throw new Error("A NIP-46 client private key is required."); + } + + if (!this.clientPublicKey) { + const tools = getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable."); + } + this.clientPublicKey = tools.getPublicKey(this.clientPrivateKey); + if (!this.clientPublicKey || !HEX64_REGEX.test(this.clientPublicKey)) { + throw new Error("Failed to derive a valid public key for the remote signer session."); + } + this.clientPublicKey = this.clientPublicKey.toLowerCase(); + } + + if (!this.remotePubkey) { + throw new Error("A remote signer pubkey is required."); + } + + if (!this.signEventWithKey) { + throw new Error("Remote signer requires a signEvent helper."); + } + + this.pendingRequests = new Map(); + this.subscription = null; + this.destroyed = false; + this.cipher = null; + this.lastSeen = 0; + this.userPubkey = ""; + this.activeSignerCache = null; + } + + get pool() { + return this.nostrClient?.pool || null; + } + + async ensurePool() { + if (this.pool) { + devLogger.debug("[nostr] Remote signer using existing nostr pool"); + return this.pool; + } + + if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") { + throw new Error("Remote signer requires a nostr client pool."); + } + + await this.nostrClient.ensurePool(); + devLogger.debug("[nostr] Remote signer pool ensured"); + return this.pool; + } + + async ensureCipher() { + if (this.cipher) { + devLogger.debug("[nostr] Reusing cached remote signer cipher", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + algorithm: this.cipher?.algorithm || this.encryptionAlgorithm || null, + }); + return this.cipher; + } + + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + throw new Error("NostrTools helpers are unavailable for remote signing."); + } + + const cipher = createNip46Cipher( + tools, + this.clientPrivateKey, + this.remotePubkey, + { preferredAlgorithm: this.encryptionAlgorithm }, + ); + this.cipher = cipher; + if (!this.encryptionAlgorithm && cipher?.algorithm) { + this.encryptionAlgorithm = cipher.algorithm; + } + devLogger.debug("[nostr] Created remote signer cipher", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + algorithm: cipher?.algorithm || null, + }); + return this.cipher; + } + + async encryptPayload(payload, context = {}) { + const { encrypt } = await this.ensureCipher(); + const serialized = typeof payload === "string" ? payload : JSON.stringify(payload); + const ciphertext = encrypt(serialized); + devLogger.debug("[nostr] Encrypted remote signer payload", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + method: context.method || null, + requestId: context.requestId || null, + payloadPreview: summarizePayloadPreviewForLog(serialized), + ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, + }); + return ciphertext; + } + + async decryptPayload(ciphertext, context = {}) { + const { decrypt } = await this.ensureCipher(); + try { + const plaintext = decrypt(ciphertext); + devLogger.debug("[nostr] Decrypted remote signer payload", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + method: context.method || null, + requestId: context.requestId || null, + ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, + plaintextPreview: summarizePayloadPreviewForLog(plaintext), + }); + return plaintext; + } catch (error) { + devLogger.warn("[nostr] Remote signer payload decryption failed", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + method: context.method || null, + requestId: context.requestId || null, + ciphertextLength: typeof ciphertext === "string" ? ciphertext.length : 0, + error: error?.message || String(error), + }); + throw error; + } + } + + async ensureSubscription() { + if (this.subscription) { + devLogger.debug("[nostr] Reusing remote signer subscription", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + relayCount: this.relays.length, + }); + return this.subscription; + } + + const pool = await this.ensurePool(); + const filters = [ + { + kinds: [NIP46_RPC_KIND], + authors: [this.remotePubkey], + "#p": [this.clientPublicKey], + }, + ]; + + const relays = this.relays.length ? this.relays : resolveNip46Relays([], this.nostrClient?.relays || []); + + const sub = pool.sub(relays, filters); + sub.on("event", (event) => { + this.handleEvent(event); + }); + sub.on("eose", () => { + // no-op; responses are push-based + }); + this.subscription = sub; + devLogger.debug("[nostr] Remote signer subscription created", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + relays, + filters, + }); + return sub; + } + + handleEvent(event) { + if (this.destroyed) { + return; + } + + if (!event || event.kind !== NIP46_RPC_KIND) { + return; + } + + if (typeof event.pubkey !== "string" || normalizeNostrPubkey(event.pubkey) !== this.remotePubkey) { + return; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const targetsClient = tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "p" && + typeof tag[1] === "string" && + normalizeNostrPubkey(tag[1]) === this.clientPublicKey, + ); + + if (!targetsClient) { + return; + } + + const eventId = typeof event.id === "string" ? event.id : ""; + devLogger.debug("[nostr] Remote signer event received", { + eventId, + remotePubkey: summarizeHexForLog(event.pubkey || ""), + clientPubkey: summarizeHexForLog(this.clientPublicKey), + createdAt: Number.isFinite(event.created_at) ? event.created_at : null, + contentLength: typeof event.content === "string" ? event.content.length : 0, + }); + + Promise.resolve() + .then(() => + this.decryptPayload(event.content, { + requestId: eventId, + }), + ) + .then((payload) => { + let parsed; + try { + parsed = JSON.parse(payload); + } catch (error) { + devLogger.warn("[nostr] Remote signer returned malformed payload:", error); + return; + } + + if (!parsed || typeof parsed !== "object") { + return; + } + + const requestId = typeof parsed.id === "string" ? parsed.id : ""; + if (!requestId || !this.pendingRequests.has(requestId)) { + return; + } + + const pending = this.pendingRequests.get(requestId); + devLogger.debug("[nostr] Remote signer response parsed", { + eventId, + requestId, + remotePubkey: summarizeHexForLog(this.remotePubkey), + method: pending?.method || null, + resultSummary: summarizeRpcResultForLog(pending?.method || "", parsed.result), + errorSummary: summarizePayloadPreviewForLog(parsed.error ?? ""), + }); + this.pendingRequests.delete(requestId); + clearTimeout(pending.timeoutId); + + this.lastSeen = Date.now(); + + if ( + parsed.result === "auth_url" && + typeof parsed.error === "string" && + parsed.error.trim() + ) { + const authError = new Error("Remote signer requires additional authentication."); + authError.code = "auth-challenge"; + authError.authUrl = parsed.error.trim(); + pending.reject(authError); + return; + } + + if (typeof parsed.error === "string" && parsed.error.trim()) { + const err = new Error(parsed.error.trim()); + err.code = "nip46-error"; + pending.reject(err); + return; + } + + pending.resolve(parsed.result ?? null); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to decrypt remote signer payload:", error); + }); + } + + rejectAllPending(error) { + if (this.pendingRequests.size) { + devLogger.warn("[nostr] Rejecting pending remote signer RPC requests", { + count: this.pendingRequests.size, + remotePubkey: summarizeHexForLog(this.remotePubkey), + error: error?.message || String(error), + }); + } + for (const [id, pending] of this.pendingRequests.entries()) { + clearTimeout(pending.timeoutId); + try { + pending.reject(error); + } catch (rejectError) { + devLogger.warn("[nostr] Pending NIP-46 promise reject failed for", id, rejectError); + } + } + this.pendingRequests.clear(); + } + + async sendRpc(method, params = [], options = {}) { + if (this.destroyed) { + throw new Error("Remote signer session has been disposed."); + } + + const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 + ? options.timeoutMs + : NIP46_RESPONSE_TIMEOUT_MS; + const retries = Number.isFinite(options.retries) && options.retries >= 0 + ? options.retries + : NIP46_MAX_RETRIES; + + let lastError = null; + + devLogger.debug("[nostr] Remote signer RPC start", { + method, + remotePubkey: summarizeHexForLog(this.remotePubkey), + timeoutMs, + retries, + params: summarizeRpcParamsForLog(method, params), + }); + + for (let attempt = 0; attempt <= retries; attempt += 1) { + await this.ensureSubscription(); + + const requestId = createNip46RequestId(); + const message = { + id: requestId, + method, + params: Array.isArray(params) ? params : [], + }; + + devLogger.debug("[nostr] Remote signer RPC attempt prepared", { + method, + attempt: attempt + 1, + requestId, + remotePubkey: summarizeHexForLog(this.remotePubkey), + }); + + let event; + try { + const content = await this.encryptPayload(message, { + method, + requestId, + }); + if (!this.signEventWithKey) { + throw new Error("Remote signer signing helper is unavailable."); + } + event = this.signEventWithKey( + { + kind: NIP46_RPC_KIND, + pubkey: this.clientPublicKey, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", this.remotePubkey]], + content, + }, + this.clientPrivateKey, + ); + devLogger.debug("[nostr] Remote signer RPC event signed", { + method, + attempt: attempt + 1, + requestId, + relayCount: this.relays.length, + contentLength: typeof event.content === "string" ? event.content.length : 0, + }); + } catch (error) { + lastError = error; + devLogger.warn("[nostr] Remote signer RPC encryption failed", { + method, + attempt: attempt + 1, + requestId, + error: error?.message || String(error), + }); + break; + } + + const responsePromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId); + const timeoutError = new Error( + `Timed out waiting for remote signer response to ${method}.`, + ); + timeoutError.code = "nip46-timeout"; + devLogger.warn("[nostr] Remote signer RPC timed out", { + method, + requestId, + attempt: attempt + 1, + timeoutMs, + }); + reject(timeoutError); + }, timeoutMs); + + this.pendingRequests.set(requestId, { + resolve, + reject, + timeoutId, + method, + }); + devLogger.debug("[nostr] Remote signer RPC awaiting response", { + method, + requestId, + attempt: attempt + 1, + }); + }); + + try { + const publishResults = await this.publishEventToRelays( + await this.ensurePool(), + this.relays, + event, + { timeoutMs: NIP46_PUBLISH_TIMEOUT_MS }, + ); + this.assertAnyRelayAccepted(publishResults, { context: method }); + devLogger.debug("[nostr] Remote signer RPC published", { + method, + requestId, + attempt: attempt + 1, + publishResults: summarizeRelayPublishResultsForLog(publishResults), + }); + } catch (error) { + const pending = this.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingRequests.delete(requestId); + } + lastError = error; + devLogger.warn("[nostr] Remote signer RPC publish failed", { + method, + requestId, + attempt: attempt + 1, + error: error?.message || String(error), + }); + continue; + } + + try { + const result = await responsePromise; + devLogger.debug("[nostr] Remote signer RPC response received", { + method, + requestId, + attempt: attempt + 1, + resultSummary: summarizeRpcResultForLog(method, result), + }); + return result; + } catch (error) { + lastError = error; + devLogger.warn("[nostr] Remote signer RPC attempt rejected", { + method, + requestId, + attempt: attempt + 1, + error: error?.message || String(error), + }); + if (error?.code === "auth-challenge") { + throw error; + } + } + } + + if (lastError) { + devLogger.warn("[nostr] Remote signer RPC exhausted", { + method, + remotePubkey: summarizeHexForLog(this.remotePubkey), + error: lastError?.message || String(lastError), + }); + throw lastError; + } + + devLogger.warn("[nostr] Remote signer RPC failed without explicit error", { + method, + remotePubkey: summarizeHexForLog(this.remotePubkey), + }); + throw new Error(`Remote signer request for ${method} failed.`); + } + + async connect({ permissions } = {}) { + const params = [this.remotePubkey]; + const requestedPermissions = permissions || this.permissions || ""; + + if (this.secret || requestedPermissions) { + params.push(this.secret || ""); + } + if (requestedPermissions) { + params.push(requestedPermissions); + } + + const result = await this.sendRpc("connect", params, { + timeoutMs: Math.max(NIP46_RESPONSE_TIMEOUT_MS, 12_000), + retries: 0, + }); + + if (this.secret) { + const normalizedResult = typeof result === "string" ? result.trim() : ""; + if (!normalizedResult || normalizedResult !== this.secret) { + const error = new Error("Remote signer secret mismatch. Rejecting connection."); + error.code = "nip46-secret-mismatch"; + throw error; + } + } + + devLogger.debug("[nostr] Remote signer connect RPC acknowledged", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + secretConfirmed: Boolean(this.secret), + resultSummary: summarizeRpcResultForLog("connect", result), + }); + return result; + } + + async getUserPubkey() { + const result = await this.sendRpc("get_public_key", [], { + timeoutMs: NIP46_RESPONSE_TIMEOUT_MS, + retries: 0, + }); + const pubkey = typeof result === "string" ? result.trim() : ""; + if (!pubkey) { + const error = new Error("Remote signer did not return a public key."); + error.code = "nip46-empty-pubkey"; + throw error; + } + this.userPubkey = normalizeNostrPubkey(pubkey); + devLogger.debug("[nostr] Remote signer get_public_key resolved", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + userPubkey: summarizeHexForLog(this.userPubkey), + }); + return this.userPubkey; + } + + async ping() { + try { + const result = await this.sendRpc("ping", [], { + timeoutMs: 5000, + retries: 0, + }); + const ok = typeof result === "string" && result.trim().toLowerCase() === "pong"; + devLogger.debug("[nostr] Remote signer ping result", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + ok, + summary: summarizeRpcResultForLog("ping", result), + }); + return ok; + } catch (error) { + devLogger.warn("[nostr] Remote signer ping failed", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + error: error?.message || String(error), + }); + return false; + } + } + + async signEvent(event, options = {}) { + if (!event || typeof event !== "object") { + throw new Error("A Nostr event is required for remote signing."); + } + + const unsigned = { + kind: event.kind, + created_at: event.created_at, + content: typeof event.content === "string" ? event.content : "", + tags: Array.isArray(event.tags) + ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) + : [], + pubkey: + typeof event.pubkey === "string" && event.pubkey.trim() + ? event.pubkey.trim() + : this.userPubkey, + }; + + const result = await this.sendRpc( + "sign_event", + [JSON.stringify(unsigned)], + { + timeoutMs: Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : NIP46_SIGN_EVENT_TIMEOUT_MS, + retries: Number.isFinite(options.retries) ? options.retries : NIP46_MAX_RETRIES, + }, + ); + + if (!result) { + const error = new Error("Remote signer returned an empty response."); + error.code = "nip46-empty-response"; + throw error; + } + + if (typeof result === "object") { + devLogger.debug("[nostr] Remote signer sign_event returned object", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + keys: Object.keys(result).slice(0, 6), + }); + return result; + } + + try { + const parsed = JSON.parse(result); + devLogger.debug("[nostr] Remote signer sign_event returned JSON", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + keys: parsed && typeof parsed === "object" ? Object.keys(parsed).slice(0, 6) : [], + }); + return parsed; + } catch (error) { + const failure = new Error("Remote signer returned malformed signed event."); + failure.code = "nip46-invalid-response"; + failure.cause = error; + devLogger.warn("[nostr] Remote signer sign_event parse failed", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + error: error?.message || String(error), + }); + throw failure; + } + } + + getActiveSigner() { + if (!this.userPubkey) { + return null; + } + + if (!this.activeSignerCache) { + this.activeSignerCache = { + type: "nip46", + pubkey: this.userPubkey, + signEvent: (event) => this.signEvent(event), + }; + } + + return this.activeSignerCache; + } + + destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + + devLogger.debug("[nostr] Destroying remote signer client", { + remotePubkey: summarizeHexForLog(this.remotePubkey), + pendingRequests: this.pendingRequests.size, + hasSubscription: Boolean(this.subscription), + }); + + if (this.subscription && typeof this.subscription.unsub === "function") { + try { + this.subscription.unsub(); + } catch (error) { + devLogger.warn("[nostr] Failed to unsubscribe remote signer session:", error); + } + } + this.subscription = null; + + this.rejectAllPending(new Error("Remote signer session closed.")); + } +} diff --git a/tests/nostr.nip46.test.mjs b/tests/nostr/nip46Client.test.js similarity index 73% rename from tests/nostr.nip46.test.mjs rename to tests/nostr/nip46Client.test.js index 449573b5..bb47a053 100644 --- a/tests/nostr.nip46.test.mjs +++ b/tests/nostr/nip46Client.test.js @@ -2,34 +2,42 @@ import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; import test from "node:test"; -const PAYLOAD = { message: "hello", count: 42 }; - const loadNostrTools = async () => { const nostrTools = await import("nostr-tools"); globalThis.nostrToolsReady = Promise.resolve(nostrTools); return nostrTools; }; +const loadNip46Module = () => import("../../js/nostr/nip46Client.js"); + +const buildSignEventStub = () => (event) => ({ + ...event, + id: "stub-id", + sig: "stub-signature", +}); + test("Nip46RpcClient encrypts payloads with nip44 conversation keys", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + const { + Nip46RpcClient, + } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSecret = utils.bytesToHex(generateSecretKey()); const clientPublicKey = getPublicKey(clientSecret); const remotePubkey = getPublicKey(remoteSecret); - const { Nip46RpcClient } = await import("../js/nostr.js"); - const rpcClient = new Nip46RpcClient({ nostrClient: { relays: [] }, clientPrivateKey: clientSecret, clientPublicKey, remotePubkey, + signEvent: buildSignEventStub(), }); - const serialized = JSON.stringify(PAYLOAD); - const ciphertext = await rpcClient.encryptPayload(PAYLOAD); + const serialized = JSON.stringify({ message: "hello", count: 42 }); + const ciphertext = await rpcClient.encryptPayload({ message: "hello", count: 42 }); assert.equal(typeof ciphertext, "string", "ciphertext should be encoded as a string"); @@ -59,21 +67,19 @@ test("Nip46RpcClient encrypts payloads with nip44 conversation keys", async () = test("decryptNip46PayloadWithKeys handles nip44.v2 ciphertext", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + const { decryptNip46PayloadWithKeys } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSecret = utils.bytesToHex(generateSecretKey()); const remotePubkey = getPublicKey(remoteSecret); - const serialized = JSON.stringify(PAYLOAD); + const serialized = JSON.stringify({ message: "hello", count: 42 }); const conversationKey = typeof nip44.v2.getConversationKey === "function" ? nip44.v2.getConversationKey(clientSecret, remotePubkey) : nip44.v2.utils.getConversationKey(clientSecret, remotePubkey); const ciphertext = nip44.v2.encrypt(serialized, conversationKey); - const { __testExports } = await import("../js/nostr.js"); - const { decryptNip46PayloadWithKeys } = __testExports; - const { plaintext, algorithm } = await decryptNip46PayloadWithKeys( clientSecret, remotePubkey, @@ -91,12 +97,16 @@ test("decryptNip46PayloadWithKeys handles nip44.v2 ciphertext", async () => { test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + const { + decryptNip46PayloadWithKeys, + normalizeNip46CiphertextPayload, + } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSecret = utils.bytesToHex(generateSecretKey()); const remotePubkey = getPublicKey(remoteSecret); - const serialized = JSON.stringify(PAYLOAD); + const serialized = JSON.stringify({ message: "hello", count: 42 }); const conversationKey = typeof nip44.v2.getConversationKey === "function" ? nip44.v2.getConversationKey(clientSecret, remotePubkey) @@ -109,8 +119,11 @@ test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async nonce, }; - const { __testExports } = await import("../js/nostr.js"); - const { decryptNip46PayloadWithKeys } = __testExports; + const normalized = normalizeNip46CiphertextPayload(structuredPayload); + assert.ok( + normalized.includes(ciphertext), + "normalizer should include the raw ciphertext from structured payloads", + ); const { plaintext: structuredPlaintext, algorithm } = await decryptNip46PayloadWithKeys( @@ -122,124 +135,71 @@ test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async assert.equal( structuredPlaintext, serialized, - "helper should decrypt ciphertext encoded as an object with nonce", + "helper should decrypt ciphertext encoded with newline nonce", ); assert.equal( algorithm, "nip44.v2", "structured payload should report nip44.v2 algorithm", ); - - const jsonPayload = JSON.stringify(structuredPayload); - const { plaintext: jsonPlaintext, algorithm: jsonAlgorithm } = - await decryptNip46PayloadWithKeys( - clientSecret, - remotePubkey, - jsonPayload, - ); - - assert.equal( - jsonPlaintext, - serialized, - "helper should also decrypt JSON-serialized handshake payloads", - ); - assert.equal( - jsonAlgorithm, - "nip44.v2", - "JSON payload should report nip44.v2 algorithm", - ); }); test("decryptNip46PayloadWithKeys decodes buffer-based handshake payloads", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + const { + decryptNip46PayloadWithKeys, + normalizeNip46CiphertextPayload, + } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSecret = utils.bytesToHex(generateSecretKey()); const remotePubkey = getPublicKey(remoteSecret); - const serialized = JSON.stringify(PAYLOAD); + const serialized = JSON.stringify({ message: "hello", count: 42 }); const conversationKey = typeof nip44.v2.getConversationKey === "function" ? nip44.v2.getConversationKey(clientSecret, remotePubkey) : nip44.v2.utils.getConversationKey(clientSecret, remotePubkey); const ciphertext = nip44.v2.encrypt(serialized, conversationKey); + const [ciphertextPart, nonce] = ciphertext.split("\n"); const ciphertextBuffer = Buffer.from(ciphertext, "utf8"); - const bufferPayload = ciphertextBuffer; - - const { __testExports } = await import("../js/nostr.js"); - const { decryptNip46PayloadWithKeys } = __testExports; + const normalized = normalizeNip46CiphertextPayload(ciphertextBuffer); + assert.ok( + normalized.includes(ciphertext), + "normalizer should extract the raw ciphertext from buffers", + ); const { plaintext: bufferPlaintext, algorithm } = await decryptNip46PayloadWithKeys( clientSecret, remotePubkey, - bufferPayload, + ciphertextBuffer, ); assert.equal( bufferPlaintext, serialized, - "helper should decrypt handshake payloads that serialize buffers", + "helper should decrypt ciphertext derived from buffers", ); assert.equal( algorithm, "nip44.v2", "buffer payload should report nip44.v2 algorithm", ); - - const jsonBufferPayload = JSON.stringify(bufferPayload); - const { plaintext: jsonBufferPlaintext, algorithm: jsonBufferAlgorithm } = - await decryptNip46PayloadWithKeys( - clientSecret, - remotePubkey, - jsonBufferPayload, - ); - - assert.equal( - jsonBufferPlaintext, - serialized, - "helper should decrypt JSON-encoded buffer handshake payloads", - ); - assert.equal( - jsonBufferAlgorithm, - "nip44.v2", - "JSON buffer payload should report nip44.v2 algorithm", - ); - - const objectBufferPayload = { - ciphertext: ciphertextBuffer, - }; - - const { plaintext: objectBufferPlaintext, algorithm: objectBufferAlgorithm } = - await decryptNip46PayloadWithKeys( - clientSecret, - remotePubkey, - objectBufferPayload, - ); - - assert.equal( - objectBufferPlaintext, - serialized, - "helper should decrypt objects containing buffer encoded ciphertext", - ); - assert.equal( - objectBufferAlgorithm, - "nip44.v2", - "object buffer payload should report nip44.v2 algorithm", - ); }); test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip04, utils } = nostrTools; + const { createNip46Cipher, normalizeNip46CiphertextPayload } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSecret = utils.bytesToHex(generateSecretKey()); const remotePubkey = getPublicKey(remoteSecret); - const serialized = JSON.stringify(PAYLOAD); + const serialized = JSON.stringify({ message: "hello", count: 42 }); const ciphertext = await nip04.encrypt(clientSecret, remotePubkey, serialized); const [ciphertextPart, iv] = ciphertext.split("?iv="); @@ -249,13 +209,10 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy }; const patchedTools = { - ...nostrTools, + ...(await import("nostr-tools")), nip44: undefined, }; - const { __testExports } = await import("../js/nostr.js"); - const { createNip46Cipher, normalizeNip46CiphertextPayload } = __testExports; - const candidates = normalizeNip46CiphertextPayload(structuredPayload); assert.ok( @@ -292,6 +249,7 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy test("parseNip46ConnectionString handles remote signer key hints", async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip19, utils } = nostrTools; + const { parseNip46ConnectionString } = await loadNip46Module(); const signerSecret = utils.bytesToHex(generateSecretKey()); const userSecret = utils.bytesToHex(generateSecretKey()); @@ -306,9 +264,6 @@ test("parseNip46ConnectionString handles remote signer key hints", async () => { `bunker://${userNpub}?remote-signer-key=${encodeURIComponent(signerNpub)}` + `&relay=${encodeURIComponent("wss://relay.example.com")}`; - const { __testExports } = await import("../js/nostr.js"); - const { parseNip46ConnectionString } = __testExports; - const parsed = parseNip46ConnectionString(uri); assert.ok(parsed, "parser should return a payload"); @@ -334,6 +289,7 @@ test( async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + const { attemptDecryptNip46HandshakePayload } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const remoteSignerSecret = utils.bytesToHex(generateSecretKey()); @@ -350,9 +306,6 @@ test( const payload = JSON.stringify({ id: "ack", result: "ok" }); const ciphertext = nip44.v2.encrypt(payload, conversationKey); - const { __testExports } = await import("../js/nostr.js"); - const { attemptDecryptNip46HandshakePayload } = __testExports; - const result = await attemptDecryptNip46HandshakePayload({ clientPrivateKey: clientSecret, candidateRemotePubkeys: [userPubkey, remoteSignerPubkey], @@ -377,6 +330,7 @@ test( async () => { const nostrTools = await loadNostrTools(); const { generateSecretKey, getPublicKey, nip04, utils } = nostrTools; + const { attemptDecryptNip46HandshakePayload } = await loadNip46Module(); const clientSecret = utils.bytesToHex(generateSecretKey()); const clientPubkey = getPublicKey(clientSecret).toLowerCase(); @@ -389,10 +343,7 @@ test( assert.ok(ivPart, "nip04 encryption should emit an iv segment"); - const encodedPayload = JSON.stringify([ciphertextPart, ivPart]); - - const { __testExports } = await import("../js/nostr.js"); - const { attemptDecryptNip46HandshakePayload } = __testExports; + const encodedPayload = `${ciphertextPart}?iv=${ivPart}`; const result = await attemptDecryptNip46HandshakePayload({ clientPrivateKey: clientSecret, @@ -417,3 +368,71 @@ test( ); }, ); + +test("Nip46RpcClient sendRpc publishes events and resolves responses", async () => { + const { Nip46RpcClient, NIP46_RPC_KIND } = await loadNip46Module(); + + const remotePubkey = "b".repeat(64); + const clientPrivateKey = "a".repeat(64); + const clientPublicKey = "c".repeat(64); + + let eventListener = null; + const pool = { + sub(relays, filters) { + return { + on(type, handler) { + if (type === "event") { + eventListener = handler; + } + }, + unsub() {}, + }; + }, + }; + + const publishCalls = []; + const client = new Nip46RpcClient({ + nostrClient: { + relays: ["wss://relay.example.com"], + pool, + async ensurePool() { + return pool; + }, + }, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays: ["wss://relay.example.com"], + signEvent: (event) => ({ ...event, id: "evt", sig: "sig" }), + publishEventToRelays: async (unusedPool, relays, event) => { + publishCalls.push({ relays, event }); + return relays.map((relay) => ({ relay, ok: true })); + }, + assertAnyRelayAccepted: () => {}, + }); + + client.ensureCipher = async () => ({ + encrypt: (payload) => (typeof payload === "string" ? payload : JSON.stringify(payload)), + decrypt: (ciphertext) => ciphertext, + }); + + const rpcPromise = client.sendRpc("ping", []); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(publishCalls.length > 0, "sendRpc should publish an event"); + assert.ok(eventListener, "sendRpc should register an event listener"); + + const [{ event }] = publishCalls; + const requestPayload = JSON.parse(event.content); + + eventListener({ + kind: NIP46_RPC_KIND, + pubkey: remotePubkey, + tags: [["p", clientPublicKey]], + content: JSON.stringify({ id: requestPayload.id, result: "pong" }), + }); + + const result = await rpcPromise; + assert.equal(result, "pong", "RPC result should resolve from response event"); +});