diff --git a/js/nostr/client.js b/js/nostr/client.js index 757eb994..a50eaabc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -165,6 +165,7 @@ import { sanitizeRelayList, readStoredNip46Session, writeStoredNip46Session, + decryptNip46Session, clearStoredNip46Session, parseNip46ConnectionString, generateNip46Secret, @@ -1479,11 +1480,15 @@ export class NostrClient { const remotePubkey = stored.remotePubkey || ""; const userPubkey = stored.userPubkey || ""; - const canReconnect = Boolean(stored.clientPrivateKey && stored.secret); + const hasEncryptedSession = + typeof stored.encryptedSecrets === "string" && !!stored.encryptedSecrets; + const canReconnect = + Boolean(stored.clientPrivateKey && stored.secret) || hasEncryptedSession; return { hasSession: true, canReconnect, + hasEncryptedSession, remotePubkey, remoteNpub: encodeHexToNpub(remotePubkey), clientPublicKey: stored.clientPublicKey || "", @@ -2234,6 +2239,7 @@ export class NostrClient { onAuthUrl, onStatus, handshakeTimeoutMs, + passphrase, } = {}) { const parsed = parseNip46ConnectionString(connectionString); if (!parsed) { @@ -2552,19 +2558,26 @@ export class NostrClient { encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", permissions: permissions || null, }); - writeStoredNip46Session({ - version: 1, - clientPublicKey, - remotePubkey, - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions, - metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey, + remotePubkey, + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions, + metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey, + secret, + }, + passphrase, + ); } else { - devLogger.debug("[nostr] Clearing stored remote signer session per request"); + devLogger.debug( + "[nostr] Clearing stored remote signer session per request", + ); clearStoredNip46Session(); } @@ -2637,15 +2650,40 @@ export class NostrClient { options && typeof options === "object" ? options : {}; const silent = normalizedOptions.silent === true; const forgetOnError = normalizedOptions.forgetOnError === true; - const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; + const passphrase = + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; - const stored = readStoredNip46Session(); + let stored = readStoredNip46Session(); if (!stored) { - const error = new Error("No remote signer session is stored on this device."); + const error = new Error( + "No remote signer session is stored on this device.", + ); error.code = "no-stored-session"; throw error; } + if (stored.encryptedSecrets) { + if (!passphrase) { + const error = new Error("Passphrase required to unlock session."); + error.code = "passphrase-required"; + throw error; + } + try { + stored = await decryptNip46Session(stored, passphrase); + } catch (decryptError) { + const error = new Error("Failed to decrypt stored session."); + error.code = "decrypt-failed"; + error.cause = decryptError; + throw error; + } + } + const relays = resolveNip46Relays(stored.relays, this.relays); devLogger.debug("[nostr] Reconnecting to stored remote signer", { @@ -2664,7 +2702,9 @@ export class NostrClient { }); if (!stored.clientPrivateKey || !stored.secret) { - const error = new Error("Stored remote signer credentials are unavailable."); + const error = new Error( + "Stored remote signer credentials are unavailable.", + ); error.code = "stored-session-missing-credentials"; throw error; } @@ -2708,17 +2748,22 @@ export class NostrClient { client.metadata = stored.metadata; const signer = await this.installNip46Client(client, { userPubkey }); - writeStoredNip46Session({ - version: 1, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays: stored.relays, - encryption: client.encryptionAlgorithm || stored.encryption || "", - permissions: stored.permissions, - metadata: stored.metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays: stored.relays, + encryption: client.encryptionAlgorithm || stored.encryption || "", + permissions: stored.permissions, + metadata: stored.metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey: stored.clientPrivateKey, + secret: stored.secret, + }, + passphrase, + ); devLogger.debug("[nostr] Stored remote signer session refreshed", { remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index cb3d021b..dc7910c5 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -14,6 +14,11 @@ */ import { isDevMode } from "../config.js"; +import { + encryptSessionPrivateKey, + decryptSessionPrivateKey, + normalizeStoredEncryptionMetadata, +} from "./sessionActor.js"; import { DEFAULT_RELAY_URLS, ensureNostrTools, @@ -223,6 +228,11 @@ export function sanitizeStoredNip46Session(candidate) { lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) ? candidate.lastConnectedAt : Date.now(), + encryptedSecrets: + typeof candidate.encryptedSecrets === "string" + ? candidate.encryptedSecrets.trim() + : "", + keyEncryption: normalizeStoredEncryptionMetadata(candidate.keyEncryption), }; } @@ -251,7 +261,7 @@ export function readStoredNip46Session() { (typeof parsed?.clientPrivateKey === "string" || typeof parsed?.secret === "string") ) { - writeStoredNip46Session(sanitized); + writeStoredNip46SessionSync(sanitized); } return sanitized; } catch (error) { @@ -267,7 +277,7 @@ export function readStoredNip46Session() { } } -export function writeStoredNip46Session(payload) { +export function writeStoredNip46SessionSync(payload) { const storage = getNip46Storage(); if (!storage) { return; @@ -285,6 +295,8 @@ export function writeStoredNip46Session(payload) { metadata: payload.metadata, userPubkey: payload.userPubkey, lastConnectedAt: payload.lastConnectedAt, + encryptedSecrets: payload.encryptedSecrets, + keyEncryption: payload.keyEncryption, } : payload; const normalized = sanitizeStoredNip46Session(sanitizedInput); @@ -304,6 +316,90 @@ export function writeStoredNip46Session(payload) { } } +export async function writeStoredNip46Session(payload, passphrase) { + if (!payload || typeof payload !== "object") { + return; + } + + const update = { ...payload }; + + if ( + passphrase && + typeof passphrase === "string" && + passphrase.trim() && + typeof update.clientPrivateKey === "string" && + update.clientPrivateKey && + typeof update.secret === "string" && + update.secret + ) { + const secrets = JSON.stringify({ + clientPrivateKey: update.clientPrivateKey, + secret: update.secret, + }); + + try { + const encrypted = await encryptSessionPrivateKey( + secrets, + passphrase.trim(), + ); + update.encryptedSecrets = encrypted.ciphertext; + update.keyEncryption = { + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + algorithm: encrypted.algorithm, + version: encrypted.version, + }; + } catch (error) { + devLogger.warn("[nostr] Failed to encrypt remote signer session:", error); + // Fall through to write sanitized (non-sensitive) data only + } + } + + // Ensure plain text keys are never persisted + delete update.clientPrivateKey; + delete update.secret; + + writeStoredNip46SessionSync(update); +} + +export async function decryptNip46Session(session, passphrase) { + if (!session || typeof session !== "object") { + return null; + } + + if ( + !session.encryptedSecrets || + typeof session.encryptedSecrets !== "string" + ) { + return { ...session }; + } + + if (!passphrase || typeof passphrase !== "string" || !passphrase.trim()) { + throw new Error("Passphrase required to decrypt session."); + } + + const payload = { + privateKeyEncrypted: session.encryptedSecrets, + encryption: session.keyEncryption, + }; + + const decryptedJson = await decryptSessionPrivateKey(payload, passphrase); + let secrets = null; + try { + secrets = JSON.parse(decryptedJson); + } catch (error) { + throw new Error("Decrypted session secrets are malformed."); + } + + return { + ...session, + clientPrivateKey: secrets.clientPrivateKey, + secret: secrets.secret, + }; +} + export function clearStoredNip46Session() { const storage = getNip46Storage(); if (!storage) {