import { isSessionActor, readStoredSessionActorEntry, decryptSessionPrivateKey, encryptSessionPrivateKey, persistSessionActor as persistSessionActorEntry, clearStoredSessionActor as clearStoredSessionActorEntry, } from "../sessionActor.js"; import { waitForNip07Extension, NIP07_EXTENSION_WAIT_TIMEOUT_MS, requestEnablePermissions, normalizePermissionMethod, readStoredNip07Permissions, writeStoredNip07Permissions, clearStoredNip07Permissions, runNip07WithRetry, DEFAULT_NIP07_ENCRYPTION_METHODS, DEFAULT_NIP07_CORE_METHODS, DEFAULT_NIP07_PERMISSION_METHODS, } from "../nip07Permissions.js"; import { setActiveSigner as setActiveSignerInRegistry, getActiveSigner as getActiveSignerFromRegistry, clearActiveSigner as clearActiveSignerInRegistry, logoutSigner as logoutSignerFromRegistry, } from "../../nostrClientRegistry.js"; import { resolveSignerCapabilities, hydrateExtensionSignerCapabilities, attachNipMethodAliases, resolveActiveSigner, buildExtensionSignerAdapter, } from "../signerCapabilities.js"; // Re-exported for existing importers (e.g. js/nostr/client.js) that historically // imported these from SignerManager. export { resolveSignerCapabilities, hydrateExtensionSignerCapabilities, attachNipMethodAliases, }; import { readStoredNip46Session, writeStoredNip46Session, clearStoredNip46Session, Nip46RpcClient, decryptNip46Session, parseNip46ConnectionString, sanitizeNip46Metadata, normalizeNip46EncryptionAlgorithm, resolveNip46Relays, encodeHexToNpub, generateNip46Secret, NIP46_RPC_KIND, NIP46_HANDSHAKE_TIMEOUT_MS, attemptDecryptNip46HandshakePayload, normalizeNostrPubkey, } from "../nip46Client.js"; import { summarizeHexForLog, summarizeSecretForLog, summarizeMetadataForLog, summarizeUrlForLog, } from "../nip46LoggingUtils.js"; import { devLogger, userLogger } from "../../utils/logger.js"; import { HEX64_REGEX } from "../../utils/hex.js"; import { createPrivateKeyCipherClosures } from "../signerHelpers.js"; import { queueSignEvent } from "../signRequestQueue.js"; import { signEventWithPrivateKey } from "../publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; import { accessControl } from "../../accessControl.js"; import { waitForRemoteSignerHandshake } from "../signerRemoteHandshake.js"; export class SignerManager { constructor(client) { this.client = client; this.pubkey = null; this.sessionActor = null; this.lockedSessionActor = null; this.nip46Client = null; this.extensionReady = false; this.extensionPermissionsGranted = false; this.extensionPermissionCache = new Map(); const storedPermissions = readStoredNip07Permissions(); if (storedPermissions && storedPermissions.size > 0) { for (const method of storedPermissions) { this.extensionPermissionCache.set(method, true); } } this.sessionActorCipherClosures = null; this.sessionActorCipherClosuresPrivateKey = null; this.remoteSignerListeners = new Set(); this.remoteSignerStatus = {}; } setActiveSigner(signer) { if (!signer || typeof signer !== "object") { return; } hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; if (!isGetter) { signer.capabilities = resolveSignerCapabilities(signer); } setActiveSignerInRegistry(signer); } getActiveSigner() { const signer = getActiveSignerFromRegistry(); hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); if (signer && typeof signer === "object") { const capsDescriptor = Object.getOwnPropertyDescriptor( signer, "capabilities", ); const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; if (!isGetter) { signer.capabilities = resolveSignerCapabilities(signer); } } return signer; } resolveActiveSigner(pubkey) { return resolveActiveSigner(pubkey); } async ensureSessionActor(force = false) { // The session actor is an ANONYMOUS telemetry key used to SIGN reactions / // view events / watch-history when they aren't tied to the login. It must // therefore carry a usable plaintext private key. A persisted nsec login is // passphrase-encrypted (privateKey ""), so adopting one as the session actor // made reactions fail with "Missing signing primitives" (a leftover saved-nsec // entry hijacked the anonymous key). Only reuse an actor that can actually // sign; otherwise fall through and generate a fresh ephemeral key. const hasUsableKey = (actor) => Boolean( actor && typeof actor.privateKey === "string" && HEX64_REGEX.test(actor.privateKey), ); if (!force) { if (hasUsableKey(this.sessionActor)) { return this.sessionActor.pubkey; } const storedEntry = readStoredSessionActorEntry(); if (storedEntry) { // Track a locked/encrypted entry (other flows read it) but don't sign with it. this.lockedSessionActor = storedEntry; } if (hasUsableKey(storedEntry)) { this.sessionActor = storedEntry; return storedEntry.pubkey; } } const tools = await ensureNostrTools(); if (!tools) { return null; } let secret; try { if (typeof tools.generateSecretKey === "function") { secret = tools.generateSecretKey(); } else if (typeof window !== "undefined" && window.crypto) { secret = new Uint8Array(32); window.crypto.getRandomValues(secret); } else { return null; } } catch (error) { devLogger.warn("[nostr] Failed to generate session actor key:", error); return null; } const hexKey = Array.from(secret) .map((b) => b.toString(16).padStart(2, "0")) .join(""); const pubkey = tools.getPublicKey(secret); const newActor = { pubkey, privateKey: hexKey, source: "session", createdAt: Date.now(), }; this.sessionActor = newActor; return newActor.pubkey; } // Forget a stored (remembered) key. Pass the account's pubkey — the no-arg // form only clears the legacy v1 slot and must never be used to log out one // account (it used to wipe EVERY saved account's key). clearStoredSessionActor(pubkey) { clearStoredSessionActorEntry(pubkey); this.sessionActor = null; this.lockedSessionActor = null; } // Forget ONE account's remembered key WITHOUT touching the current session's // in-memory actors (used when removing a non-active saved profile). Only drops // the in-memory references if they belong to the removed account. forgetStoredSessionActor(pubkey) { const target = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; if (!target) { return; } clearStoredSessionActorEntry(target); const matches = (actor) => typeof actor?.pubkey === "string" && actor.pubkey.trim().toLowerCase() === target; if (matches(this.lockedSessionActor)) { this.lockedSessionActor = null; } if (matches(this.sessionActor) && !this.sessionActor?.privateKey) { this.sessionActor = null; } } async ensureActiveSignerForPubkey(pubkey) { const normalizedPubkey = typeof pubkey === "string" && pubkey.trim() ? pubkey.trim().toLowerCase() : ""; const existingSigner = this.resolveActiveSigner(normalizedPubkey); if (existingSigner && typeof existingSigner.signEvent === "function") { return existingSigner; } let extension = typeof window !== "undefined" && window && window.nostr ? window.nostr : null; if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) { try { await waitForNip07Extension(NIP07_EXTENSION_WAIT_TIMEOUT_MS); extension = window.nostr; } catch (error) { // Fall through to existing signer check } } const recheckedSigner = this.resolveActiveSigner(normalizedPubkey); if (recheckedSigner && typeof recheckedSigner.signEvent === "function") { return recheckedSigner; } if (!extension) { return existingSigner; } let extensionPubkey = normalizedPubkey; if (typeof extension.getPublicKey === "function") { try { const retrieved = await runNip07WithRetry( () => extension.getPublicKey(), { label: "extension.getPublicKey" }, ); if (typeof retrieved === "string" && retrieved.trim()) { extensionPubkey = retrieved.trim().toLowerCase(); } } catch (error) { devLogger.warn( "[nostr] Failed to hydrate active signer from extension pubkey:", error, ); return existingSigner; } } if (normalizedPubkey && extensionPubkey !== normalizedPubkey) { return existingSigner; } const raceWinner = this.resolveActiveSigner(extensionPubkey || normalizedPubkey); if (raceWinner && typeof raceWinner.signEvent === "function") { return raceWinner; } // Registry empty (e.g. page refresh restored the pubkey/UI but no fresh // loginWithExtension ran): build + register the adapter from the live // extension so getActiveSigner()/decrypt/sign work app-wide (storage unlock) // rather than relying on per-call fallbacks. Guarded on a matching pubkey so // we never adopt a different account. const resolvedPubkey = extensionPubkey || normalizedPubkey; if ( resolvedPubkey && (!normalizedPubkey || resolvedPubkey === normalizedPubkey) && typeof extension.signEvent === "function" ) { const adapter = buildExtensionSignerAdapter(extension, resolvedPubkey); this.pubkey = resolvedPubkey; this.setActiveSigner(adapter); // Return through resolveActiveSigner so capability aliases // (nip04Decrypt/nip44Decrypt) are hydrated onto the adapter. return this.resolveActiveSigner(resolvedPubkey) || adapter; } return null; } async ensureExtensionPermissions( requiredMethods = DEFAULT_NIP07_CORE_METHODS, { context = "general" } = {}, ) { const methods = Array.isArray(requiredMethods) ? requiredMethods : [requiredMethods]; if (!methods.length) { return { ok: true }; } const cacheKey = methods.slice().sort().join(","); if (this.extensionPermissionCache.has(cacheKey)) { return { ok: true }; } const cachedPermissions = readStoredNip07Permissions(); const missing = methods.filter( (method) => !cachedPermissions.has(method), ); if (missing.length === 0) { this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); for (const method of methods) { this.extensionPermissionCache.set(method, true); } return { ok: true }; } let extension = null; try { extension = await waitForNip07Extension(); } catch (error) { return { ok: false, error: "extension-missing" }; } if (!extension && typeof window !== "undefined") { extension = window.nostr; } try { const response = await requestEnablePermissions(extension, missing); if (response?.ok) { writeStoredNip07Permissions(missing); this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); for (const method of missing) { this.extensionPermissionCache.set(method, true); } return { ok: true }; } return { ok: false, error: response?.error || "permission-denied", }; } catch (error) { devLogger.warn("[nostr] Extension permission request failed:", error); return { ok: false, error: "request-failed" }; } } async loginWithExtension(options = {}) { let extension = null; let attempts = 0; const maxAttempts = 3; if (this.extensionReady && typeof window !== "undefined") { extension = window.nostr || null; if (!extension) { this.extensionReady = false; } } if (!extension && typeof window !== "undefined" && window.nostr) { extension = window.nostr; this.extensionReady = true; } else { while (!extension && attempts < maxAttempts) { try { const timeout = attempts === 0 ? 1500 : 3000; await waitForNip07Extension(timeout); extension = window.nostr; this.extensionReady = Boolean(extension); } catch (waitError) { devLogger.log( `Timed out waiting for extension injection (attempt ${ attempts + 1 }/${maxAttempts}):`, waitError, ); } if (extension) { break; } attempts++; if (attempts < maxAttempts) { devLogger.log("Retrying NIP-07 detection..."); await new Promise((resolve) => setTimeout(resolve, 500)); } } } if (!extension) { devLogger.log("No Nostr extension found"); throw new Error("Please install a Nostr extension (Alby, nos2x, etc.)."); } const { allowAccountSelection = false, expectPubkey, getPublicKeyTimeoutMs, getPublicKeyRetryMultiplier, } = typeof options === "object" && options !== null ? options : {}; const normalizedExpectedPubkey = typeof expectPubkey === "string" && expectPubkey.trim() ? expectPubkey.trim().toLowerCase() : null; if (typeof extension.getPublicKey !== "function") { throw new Error( "This NIP-07 extension is missing getPublicKey support. Please update the extension.", ); } const permissionResult = await this.client.ensureExtensionPermissions( DEFAULT_NIP07_PERMISSION_METHODS, { context: "login", logMetrics: true, showStatus: false }, ); if (!permissionResult.ok) { const permissionErrorDetail = permissionResult && typeof permissionResult === "object" ? permissionResult.error : null; const permissionDetailMessage = permissionErrorDetail instanceof Error && typeof permissionErrorDetail.message === "string" && permissionErrorDetail.message.trim() ? permissionErrorDetail.message.trim() : typeof permissionErrorDetail === "string" && permissionErrorDetail.trim() ? permissionErrorDetail.trim() : ""; const denialMessage = permissionDetailMessage ? `The NIP-07 extension denied the permission request (${permissionDetailMessage}). Please approve the prompt and try again.` : 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.'; const denialError = new Error(denialMessage); if (permissionResult.error) { denialError.cause = permissionResult.error; } throw denialError; } const pubkey = await runNip07WithRetry( () => extension.getPublicKey(), { label: "extension.getPublicKey", timeoutMs: getPublicKeyTimeoutMs, retryMultiplier: getPublicKeyRetryMultiplier, }, ); if (!pubkey) { throw new Error("Extension did not return a public key."); } const normalized = pubkey.toLowerCase(); if (normalizedExpectedPubkey && normalized !== normalizedExpectedPubkey) { throw new Error("Extension returned a different public key than expected."); } this.pubkey = normalized; const adapter = buildExtensionSignerAdapter(extension, normalized); this.setActiveSigner(adapter); return { pubkey: normalized, signer: adapter }; } installNip46Client(nip46Client, { userPubkey } = {}) { if (!nip46Client) { return; } this.nip46Client = nip46Client; if (userPubkey) { this.pubkey = userPubkey; this.setActiveSigner(nip46Client); } this.emitRemoteSignerChange({ state: "connected", userPubkey: userPubkey || this.pubkey, remotePubkey: nip46Client.remotePubkey, }); } /** * Prepares a NIP-46 `nostrconnect://` handshake: generates an ephemeral client * keypair and builds the connection URI the user shares with their remote * signer (link/QR). The returned handshake is then handed to * connectRemoteSigner(). This is the "generate connect link" login path; it was * previously only implemented on the unused Nip46Connector, so the client's * `prepareRemoteSignerHandshake` proxy had nothing to call. * * @param {object} [params] * @param {object} [params.metadata] * @param {string[]} [params.relays] * @param {string} [params.secret] * @param {string} [params.permissions] * @returns {Promise} handshake { connectionString, clientPrivateKey, clientPublicKey, relays, secret, permissions, metadata } */ async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) { const tools = await ensureNostrTools(); if ( !tools || typeof tools.generateSecretKey !== "function" || typeof tools.getPublicKey !== "function" ) { throw new Error( "Nostr tools are unavailable; cannot prepare a remote signer handshake.", ); } const secretKey = tools.generateSecretKey(); const clientPrivateKey = Array.from(secretKey) .map((b) => b.toString(16).padStart(2, "0")) .join(""); const clientPublicKey = tools.getPublicKey(secretKey); const sanitizedMetadata = sanitizeNip46Metadata(metadata); const requestedPermissions = typeof permissions === "string" && permissions.trim() ? permissions.trim() : ""; const resolvedRelays = resolveNip46Relays(relays, this.client?.relays); const handshakeSecret = typeof secret === "string" && secret.trim() ? secret.trim() : generateNip46Secret(); const params = []; for (const relay of resolvedRelays) { params.push(`relay=${encodeURIComponent(relay)}`); } if (handshakeSecret) { params.push(`secret=${encodeURIComponent(handshakeSecret)}`); } if (requestedPermissions) { params.push(`perms=${encodeURIComponent(requestedPermissions)}`); } for (const [key, value] of Object.entries(sanitizedMetadata)) { params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } const query = params.length ? `?${params.join("&")}` : ""; const uri = `nostrconnect://${clientPublicKey}${query}`; devLogger.debug("[nostr] Prepared remote signer handshake", { clientPublicKey: summarizeHexForLog(clientPublicKey), relays: resolvedRelays, permissions: requestedPermissions || null, }); return { type: "client", connectionString: uri, uri, clientPrivateKey, clientPublicKey, relays: resolvedRelays, secret: handshakeSecret, permissions: requestedPermissions, metadata: sanitizedMetadata, }; } /** * Runs the access-control validator against a remote signer's resolved user * pubkey. The validator (from the auth provider) throws on denial (blocked / * not permitted) or returns false; either rejects the login. No-op when no * validator is supplied. */ async _enforceRemoteSignerValidator(validator, userPubkey) { if (typeof validator !== "function") { return; } const isValid = await validator(userPubkey); if (isValid === false) { const error = new Error("Access denied."); error.code = "remote-signer-access-denied"; throw error; } } /** * Waits for a remote signer to acknowledge a `nostrconnect://` handshake. * Subscribes to NIP-46 RPC events addressed to the client pubkey; the first * acknowledgement (matching the handshake secret, or a generic ack) reveals * the remote signer's pubkey, which is needed before the RPC client can be * constructed. Ported from the (unused) Nip46Connector. * * @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>} */ async _waitForRemoteSignerHandshake(options = {}) { return waitForRemoteSignerHandshake(this, options); } async connectRemoteSigner({ connectionString, remember = true, clientPrivateKey: providedClientPrivateKey = "", clientPublicKey: providedClientPublicKey = "", relays: providedRelays = [], secret: providedSecret = "", permissions: providedPermissions = "", metadata: providedMetadata = {}, onAuthUrl, onStatus, handshakeTimeoutMs, passphrase, validator, } = {}) { const parsed = parseNip46ConnectionString(connectionString); if (!parsed) { const error = new Error( "Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.", ); error.code = "invalid-connection-string"; throw error; } const baseMetadata = sanitizeNip46Metadata(parsed.metadata); const overrideMetadata = sanitizeNip46Metadata(providedMetadata); const metadata = { ...baseMetadata, ...overrideMetadata }; devLogger.debug("[nostr] Connecting to remote signer", { connectionType: parsed.type, parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""), providedClientPublicKey: summarizeHexForLog(providedClientPublicKey), providedClientPrivateKey: summarizeSecretForLog( typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "", ), providedSecret: summarizeSecretForLog(providedSecret), providedPermissions: providedPermissions || null, handshakeTimeoutMs, parsedRelays: parsed.relays, overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0, metadataKeys: summarizeMetadataForLog(metadata), }); let clientPrivateKey = providedClientPrivateKey || ""; if (!clientPrivateKey) { const tools = await ensureNostrTools(); if (tools && typeof tools.generateSecretKey === 'function') { const secret = tools.generateSecretKey(); // Convert to hex clientPrivateKey = Array.from(secret).map(b => b.toString(16).padStart(2, '0')).join(''); } else if (typeof window !== 'undefined' && window.crypto) { const randomBytes = new Uint8Array(32); window.crypto.getRandomValues(randomBytes); clientPrivateKey = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join(''); } else { // Fallback for non-secure contexts (not ideal but functional for test/dev) clientPrivateKey = generateNip46Secret(64); } } const handleStatus = (status) => { if (typeof onStatus !== "function") { return; } try { onStatus(status); } catch (error) { devLogger.warn("[nostr] Remote signer status callback threw:", error); } }; const handleAuthChallenge = async (url, context = {}) => { if (typeof onAuthUrl !== "function" || !url) { return; } try { devLogger.debug("[nostr] Remote signer auth challenge surfaced", { url: summarizeUrlForLog(url), context, }); const result = onAuthUrl(url, context); if (result && typeof result.then === "function") { await result.catch((error) => { devLogger.warn("[nostr] Auth challenge callback promise rejected:", error); }); } } catch (error) { devLogger.warn("[nostr] Auth challenge callback threw:", error); } }; await this.client.ensurePool(); // A nostrconnect:// link is client-initiated: the remote signer's pubkey is // unknown until the user's signer scans the QR / opens the link and // acknowledges. Wait for that ACK to learn the remote pubkey before building // the RPC client (which requires it). bunker:// URIs already carry it. let remotePubkey = parsed.remotePubkey; if (!remotePubkey && parsed.type === "client") { const clientPublicKey = providedClientPublicKey || parsed.clientPublicKey || ""; handleStatus({ phase: "handshake", state: "waiting", message: "Waiting for your signer to approve the connection…", }); const ack = await this._waitForRemoteSignerHandshake({ clientPrivateKey, clientPublicKey, relays: resolveNip46Relays(parsed.relays, providedRelays), secret: providedSecret || parsed.secret, onAuthUrl: handleAuthChallenge, onStatus: handleStatus, timeoutMs: handshakeTimeoutMs, }); remotePubkey = ack.remotePubkey; if (!remotePubkey) { throw new Error("Remote signer did not provide a usable pubkey."); } } this.nip46Client = new Nip46RpcClient({ nostrClient: this.client, relays: resolveNip46Relays(parsed.relays, providedRelays), clientPrivateKey: clientPrivateKey, remotePubkey, secret: providedSecret || parsed.secret, signEvent: signEventWithPrivateKey, }); // Subscribe to events from the NIP46 client? // The previous code had onStatusChange in the constructor which Nip46RpcClient doesn't seem to support directly based on the read_file output of nip46Client.js. // However, Nip46RpcClient has no public event emitter interface shown in the file read. // We will just use it to connect. this.emitRemoteSignerChange({ state: "connecting" }); try { const result = await this.nip46Client.connect({ permissions: providedPermissions, }); if (remember) { await writeStoredNip46Session( { version: 1, clientPrivateKey: this.nip46Client.clientPrivateKey, clientPublicKey: this.nip46Client.clientPublicKey, remotePubkey: this.nip46Client.remotePubkey, relays: this.nip46Client.relays, encryption: this.nip46Client.encryptionAlgorithm, secret: this.nip46Client.secret, permissions: this.nip46Client.permissions, metadata: this.nip46Client.metadata, userPubkey: this.nip46Client.userPubkey, lastConnectedAt: Date.now(), }, passphrase ); } const userPubkey = await this.nip46Client.getUserPubkey(); // Enforce access control BEFORE activating the signer, so a blocked / // non-permitted pubkey can't establish a session via a remote signer. await this._enforceRemoteSignerValidator(validator, userPubkey); this.pubkey = userPubkey; this.setActiveSigner(this.nip46Client.getActiveSigner()); this.emitRemoteSignerChange(); return { ok: true, result, pubkey: userPubkey }; } catch(err) { this.nip46Client = null; this.emitRemoteSignerChange({ state: "error", error: err }); throw err; } } async useStoredRemoteSigner(options = {}) { const normalizedOptions = options && typeof options === "object" ? options : {}; const silent = normalizedOptions.silent === true; const forgetOnError = normalizedOptions.forgetOnError === true; const passphrase = typeof normalizedOptions.passphrase === "string" ? normalizedOptions.passphrase : null; const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; // When switching accounts, select that account's stored session so we // reconnect the one the user asked for — not just the last-connected default. const targetPubkey = typeof normalizedOptions.pubkey === "string" ? normalizedOptions.pubkey.trim().toLowerCase() : ""; let stored = readStoredNip46Session(targetPubkey || undefined); if (!stored) { const error = new Error( "No remote signer session is stored on this device.", ); error.code = "no-stored-session"; throw error; } if (stored.encryptedSecrets) { if (!passphrase) { const error = new Error("Passphrase required to unlock session."); error.code = "passphrase-required"; throw error; } try { stored = await decryptNip46Session(stored, passphrase); } catch (decryptError) { const error = new Error("Failed to decrypt stored session."); error.code = "decrypt-failed"; error.cause = decryptError; throw error; } } const relays = resolveNip46Relays(stored.relays, this.client.relays); devLogger.debug("[nostr] Reconnecting to stored remote signer", { remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), relays, encryption: stored.encryption || "", permissions: stored.permissions || null, hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), }); this.emitRemoteSignerChange({ state: "connecting", relays, remotePubkey: stored.remotePubkey, }); await this.client.ensurePool(); this.nip46Client = new Nip46RpcClient({ nostrClient: this.client, relays, clientPrivateKey: stored.clientPrivateKey, remotePubkey: stored.remotePubkey, secret: stored.secret, encryption: stored.encryption, signEvent: signEventWithPrivateKey, }); try { // Just verify we can ping or get pubkey await this.nip46Client.ensureSubscription(); const pubkey = await this.nip46Client.getUserPubkey(); // Re-check access control when restoring a stored session, so a pubkey // that has since been blocked can't silently reconnect. await this._enforceRemoteSignerValidator(validator, pubkey); this.pubkey = pubkey; this.setActiveSigner(this.nip46Client.getActiveSigner()); this.emitRemoteSignerChange(); return { ok: true, pubkey }; } catch (err) { this.nip46Client = null; this.emitRemoteSignerChange({ state: "error", error: err }); // Always forget a stored session that was rejected by access control — // no point keeping a blocked pubkey's session to retry. Forget only the // account we tried, leaving other saved sessions switchable. if (forgetOnError || err?.code === "remote-signer-access-denied") { clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined); } throw err; } } // Access-control gate for restoring a stored signer. Waits for the admin // lists to load (so the decision is accurate on a cold start) and throws if // the pubkey is no longer allowed, matching the fresh-login/connect paths. // useStoredRemoteSigner clears the stored session when this rejects. buildAccessControlValidator() { return async (pubkey) => { try { if (typeof accessControl.waitForReady === "function") { await accessControl.waitForReady(); } } catch (error) { // If readiness can't be confirmed, fall through to the live check. } if (!accessControl.canAccess(pubkey)) { if (accessControl.isBlacklisted(pubkey)) { throw new Error("Your account has been blocked on this platform."); } throw new Error("Access restricted to admins and moderators only."); } return true; }; } async scheduleStoredRemoteSignerRestore() { const stored = readStoredNip46Session(); // [nip46-diag] TEMP (todo #33): trace whether a NIP-46 session is restored on // load — the "unknown user after refresh" symptom. Remove with the fix. userLogger.info("[nip46-diag] restore: scheduleStoredRemoteSignerRestore", { hasStored: !!stored, storedPubkey: stored?.pubkey || stored?.userPubkey || null, storedKeys: stored ? Object.keys(stored) : [], }); if (!stored) { return; } const decryptAndConnect = async () => { try { // Enforce access control on the silent startup restore too — without a // validator a since-blocked pubkey's stored session would reconnect // unchecked. await this.useStoredRemoteSigner({ silent: true, validator: this.buildAccessControlValidator(), }); userLogger.info("[nip46-diag] restore: succeeded", { resolvedPubkey: this.pubkey || null, }); } catch (err) { // Surfaced via userLogger so it's visible on unstable (devLogger is gated). userLogger.warn("[nip46-diag] restore: FAILED", { message: err?.message || String(err), code: err?.code || null, }); } }; decryptAndConnect(); } async disconnectRemoteSigner({ keepStored = false } = {}) { // [nip46-diag] TEMP (todo #33): the single shared nip46Client is torn down here; // logout/switch calling this is the suspected "logs out everyone" cause. userLogger.info("[nip46-diag] disconnectRemoteSigner", { keepStored, hadClient: !!this.nip46Client, pubkey: this.pubkey || null, stack: new Error().stack?.split("\n").slice(2, 5).join(" <- "), }); // Abort an in-flight nostrconnect:// handshake wait, if any. if (typeof this.pendingHandshakeCancel === "function") { try { this.pendingHandshakeCancel(); } catch (error) { // ignore cancel errors } } if (this.nip46Client) { if (typeof this.nip46Client.destroy === 'function') { this.nip46Client.destroy(); } this.nip46Client = null; } if (!keepStored) { clearStoredNip46Session(); } this.emitRemoteSignerChange(); } getStoredNip46Metadata(pubkey) { const stored = readStoredNip46Session(pubkey) || {}; const userPubkey = typeof stored.userPubkey === "string" ? stored.userPubkey : ""; return { hasSession: Boolean(stored.remotePubkey && stored.clientPrivateKey), remotePubkey: stored.remotePubkey || "", metadata: stored.metadata || {}, relays: stored.relays || [], encryption: stored.encryption || "", userPubkey, userNpub: encodeHexToNpub(userPubkey), }; } getRemoteSignerStatus() { return { ...this.remoteSignerStatus }; } emitRemoteSignerChange(status = {}) { const stored = this.getStoredNip46Metadata(); const nextState = typeof status.state === "string" && status.state.trim() ? status.state.trim() : this.nip46Client ? "connected" : stored.hasSession ? "stored" : "idle"; const remotePubkey = (typeof status.remotePubkey === "string" && status.remotePubkey.trim()) || this.nip46Client?.remotePubkey || stored.remotePubkey || ""; const userPubkey = (typeof status.userPubkey === "string" && status.userPubkey.trim()) || this.nip46Client?.userPubkey || stored.userPubkey || ""; const metadataCandidate = status.metadata || this.nip46Client?.metadata || stored.metadata || {}; const metadata = metadataCandidate && typeof metadataCandidate === "object" ? { ...metadataCandidate } : {}; const relays = Array.isArray(status.relays) ? status.relays.slice() : (this.nip46Client?.relays || stored.relays || []).slice(); const encryption = (typeof status.encryption === "string" && status.encryption.trim() ? normalizeNip46EncryptionAlgorithm(status.encryption) : "") || this.nip46Client?.encryptionAlgorithm || stored.encryption || ""; const snapshot = { state: nextState, remotePubkey, userPubkey, relays, metadata, encryption, error: status.error || null, }; this.remoteSignerStatus = snapshot; for (const listener of this.remoteSignerListeners) { try { listener(snapshot); } catch (error) { devLogger.warn("[nostr] Remote signer listener threw:", error); } } return snapshot; } onRemoteSignerChange(listener) { if (typeof listener !== "function") { return () => {}; } this.remoteSignerListeners.add(listener); return () => { this.remoteSignerListeners.delete(listener); }; } logout() { const previousPubkey = this.pubkey; // [nip46-diag] TEMP (todo #33): trace logout scope — which pubkey + whether a // shared remote-signer client is being torn down (affects other NIP-46 profiles). userLogger.info("[nip46-diag] logout", { previousPubkey: previousPubkey || null, hadRemoteClient: !!this.nip46Client, sessionActorSource: this.sessionActor?.source || null, }); this.pubkey = null; logoutSignerFromRegistry(previousPubkey); const previousSessionActor = this.sessionActor; this.sessionActor = null; this.sessionActorCipherClosures = null; this.sessionActorCipherClosuresPrivateKey = null; if (this.nip46Client) { this.disconnectRemoteSigner({ keepStored: true }); } const shouldClearStoredSession = previousSessionActor && previousSessionActor.source === "nsec" && previousSessionActor.persisted !== true; if (shouldClearStoredSession) { // Forget ONLY the departing account's remembered key. The no-arg clear // used to wipe the whole per-account map, so logging out one non-persisted // nsec login destroyed every OTHER saved account's key. Skip entirely when // we can't name the account — never wipe-all implicitly. const clearTarget = previousSessionActor.pubkey || previousPubkey || ""; if (clearTarget) { this.lockedSessionActor = null; this.clearStoredSessionActor(clearTarget); } } if ( this.extensionPermissionCache && typeof this.extensionPermissionCache.clear === "function" ) { this.extensionPermissionCache.clear(); } clearStoredNip07Permissions(); this.extensionReady = false; this.extensionPermissionsGranted = false; this.emitRemoteSignerChange(); } installNip46Client(rpcClient, { userPubkey } = {}) { this.nip46Client = rpcClient; if (userPubkey) { this.pubkey = userPubkey; } if (rpcClient && typeof rpcClient.getActiveSigner === "function") { const signer = rpcClient.getActiveSigner(); if (signer) { this.setActiveSigner(signer); } } this.emitRemoteSignerChange(); } async derivePrivateKeyFromSecret(secret) { if (!secret || typeof secret !== "string") { throw new Error("A private key or nsec string is required."); } const trimmed = secret.trim(); const tools = (await ensureNostrTools()) || getCachedNostrTools(); // Handle hex private key if (HEX64_REGEX.test(trimmed)) { const privateKeyHex = trimmed.toLowerCase(); if (!tools || typeof tools.getPublicKey !== "function") { throw new Error("Nostr tools unavailable for key derivation."); } const pubkey = tools.getPublicKey(privateKeyHex); return { privateKey: privateKeyHex, pubkey }; } // Handle nsec bech32 encoding if (trimmed.startsWith("nsec1")) { const nip19 = tools?.nip19; if (!nip19 || typeof nip19.decode !== "function") { throw new Error("Nostr tools unavailable for nsec decoding."); } const decoded = nip19.decode(trimmed); if (!decoded || decoded.type !== "nsec" || !decoded.data) { throw new Error("Invalid nsec key."); } const secretBytes = decoded.data; const privateKeyHex = typeof secretBytes === "string" ? secretBytes : Array.from(secretBytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); if (!HEX64_REGEX.test(privateKeyHex)) { throw new Error("Decoded nsec key is invalid."); } const pubkey = tools.getPublicKey(privateKeyHex); return { privateKey: privateKeyHex, pubkey }; } throw new Error("Unrecognized key format. Provide a hex private key or nsec string."); } async registerPrivateKeySigner({ privateKey, pubkey, persist, passphrase, validator } = {}) { if (!privateKey || typeof privateKey !== "string" || !HEX64_REGEX.test(privateKey)) { throw new Error("A valid hex private key is required."); } const normalizedPrivateKey = privateKey.toLowerCase(); const normalizedPubkey = typeof pubkey === "string" && pubkey.trim() ? pubkey.trim().toLowerCase() : null; if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) { throw new Error("A valid hex public key is required."); } if (typeof validator === "function") { validator(normalizedPubkey); } const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey); const adapter = { type: "nsec", pubkey: normalizedPubkey, signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey), ...cipherClosures, }; this.setActiveSigner(adapter); this.pubkey = normalizedPubkey; if (persist && passphrase) { try { const encrypted = await encryptSessionPrivateKey(normalizedPrivateKey, passphrase); persistSessionActorEntry({ pubkey: normalizedPubkey, encrypted, source: "nsec", persisted: true, }); this.sessionActor = { pubkey: normalizedPubkey, source: "nsec", persisted: true }; } catch (error) { devLogger.warn("[nostr] Failed to persist private key signer:", error); } } return normalizedPubkey; } }