diff --git a/js/nostr/client.js b/js/nostr/client.js index 9b45e4aa..f53b6fac 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -168,6 +168,7 @@ import { setActiveSigner as setActiveSignerInRegistry, getActiveSigner as getActiveSignerFromRegistry, clearActiveSigner as clearActiveSignerInRegistry, + logoutSigner as logoutSignerFromRegistry, resolveActiveSigner as resolveActiveSignerFromRegistry, } from "../nostrClientRegistry.js"; import { createNip07Adapter } from "./adapters/nip07Adapter.js"; @@ -346,6 +347,10 @@ function clearActiveSigner() { clearActiveSignerInRegistry(); } +function logoutSigner(pubkey) { + logoutSignerFromRegistry(pubkey); +} + function resolveActiveSigner(pubkey) { const signer = resolveActiveSignerFromRegistry(pubkey); hydrateExtensionSignerCapabilities(signer); @@ -1199,9 +1204,11 @@ export class NostrClient { const remotePubkey = stored.remotePubkey || ""; const userPubkey = stored.userPubkey || ""; + const canReconnect = Boolean(stored.clientPrivateKey && stored.secret); return { hasSession: true, + canReconnect, remotePubkey, remoteNpub: encodeHexToNpub(remotePubkey), clientPublicKey: stored.clientPublicKey || "", @@ -2068,12 +2075,10 @@ export class NostrClient { }); writeStoredNip46Session({ version: 1, - clientPrivateKey, clientPublicKey, remotePubkey, relays, encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - secret, permissions, metadata, userPubkey, @@ -2155,7 +2160,7 @@ export class NostrClient { relays, encryption: stored.encryption || "", permissions: stored.permissions || null, - hasSecret: Boolean(stored.secret), + hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), }); this.emitRemoteSignerChange({ @@ -2165,6 +2170,12 @@ export class NostrClient { metadata: stored.metadata, }); + if (!stored.clientPrivateKey || !stored.secret) { + const error = new Error("Stored remote signer credentials are unavailable."); + error.code = "stored-session-missing-credentials"; + throw error; + } + const client = new Nip46RpcClient({ nostrClient: this, clientPrivateKey: stored.clientPrivateKey, @@ -2186,8 +2197,13 @@ export class NostrClient { const signer = await this.installNip46Client(client, { userPubkey }); writeStoredNip46Session({ - ...stored, + 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(), }); @@ -2262,7 +2278,7 @@ export class NostrClient { } const stored = this.getStoredNip46Metadata(); - if (!stored.hasSession) { + if (!stored.hasSession || !stored.canReconnect) { return null; } @@ -2289,7 +2305,7 @@ export class NostrClient { const activeSigner = getActiveSigner(); if (activeSigner?.type === "nip46") { - clearActiveSigner(); + logoutSigner(activeSigner.pubkey); } if (!keepStored) { @@ -2642,14 +2658,7 @@ export class NostrClient { return null; } - const { pubkey, privateKey, privateKeyEncrypted, encryption, createdAt } = entry; - if (privateKey && HEX64_REGEX.test(privateKey)) { - return { - pubkey, - privateKey: privateKey.toLowerCase(), - createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), - }; - } + const { pubkey, privateKeyEncrypted, encryption, createdAt } = entry; if (privateKeyEncrypted && encryption) { this.lockedSessionActor = { @@ -2672,7 +2681,7 @@ export class NostrClient { return null; } - const { pubkey, privateKeyEncrypted, encryption, createdAt, privateKey } = entry; + const { pubkey, privateKeyEncrypted, encryption, createdAt } = entry; const normalizedCreatedAt = Number.isFinite(createdAt) ? createdAt : Date.now(); @@ -2693,14 +2702,6 @@ export class NostrClient { this.lockedSessionActor = null; - if (privateKey) { - return { - pubkey, - hasEncryptedKey: false, - createdAt: normalizedCreatedAt, - }; - } - if (pubkey) { return { pubkey, @@ -3878,8 +3879,9 @@ export class NostrClient { } logout() { + const previousPubkey = this.pubkey; this.pubkey = null; - clearActiveSigner(); + logoutSigner(previousPubkey); const previousSessionActor = this.sessionActor; this.sessionActor = null; this.sessionActorCipherClosures = null; diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index fe75b5b7..62de7fe2 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -95,10 +95,6 @@ export function sanitizeStoredNip46Session(candidate) { 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() @@ -108,7 +104,7 @@ export function sanitizeStoredNip46Session(candidate) { ? candidate.remotePubkey.trim().toLowerCase() : ""; - if (!clientPrivateKey || !remotePubkey) { + if (!remotePubkey) { return null; } @@ -138,7 +134,6 @@ export function sanitizeStoredNip46Session(candidate) { return { version: 1, - clientPrivateKey, clientPublicKey, remotePubkey, relays, @@ -149,10 +144,6 @@ export function sanitizeStoredNip46Session(candidate) { ? candidate.algorithm : "", ), - secret: - typeof candidate.secret === "string" && candidate.secret.trim() - ? candidate.secret.trim() - : "", permissions: typeof candidate.permissions === "string" && candidate.permissions.trim() ? candidate.permissions.trim() @@ -187,7 +178,15 @@ export function readStoredNip46Session() { try { const parsed = JSON.parse(raw); - return sanitizeStoredNip46Session(parsed); + const sanitized = sanitizeStoredNip46Session(parsed); + if ( + sanitized && + (typeof parsed?.clientPrivateKey === "string" || + typeof parsed?.secret === "string") + ) { + writeStoredNip46Session(sanitized); + } + return sanitized; } catch (error) { try { storage.removeItem(NIP46_SESSION_STORAGE_KEY); @@ -207,7 +206,21 @@ export function writeStoredNip46Session(payload) { return; } - const normalized = sanitizeStoredNip46Session(payload); + const sanitizedInput = + payload && typeof payload === "object" + ? { + version: payload.version, + clientPublicKey: payload.clientPublicKey, + remotePubkey: payload.remotePubkey, + relays: payload.relays, + encryption: payload.encryption, + permissions: payload.permissions, + metadata: payload.metadata, + userPubkey: payload.userPubkey, + lastConnectedAt: payload.lastConnectedAt, + } + : payload; + const normalized = sanitizeStoredNip46Session(sanitizedInput); if (!normalized) { try { storage.removeItem(NIP46_SESSION_STORAGE_KEY); diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js index b8374673..eaf51127 100644 --- a/js/nostr/sessionActor.js +++ b/js/nostr/sessionActor.js @@ -7,6 +7,10 @@ export const SESSION_ACTOR_KDF_HASH = "SHA-256"; export const SESSION_ACTOR_ENCRYPTION_ALGORITHM = "AES-GCM"; export const SESSION_ACTOR_SALT_BYTES = 16; export const SESSION_ACTOR_IV_BYTES = 12; +const SESSION_ACTOR_DB_NAME = "bitvidSessionActor"; +const SESSION_ACTOR_DB_VERSION = 1; +const SESSION_ACTOR_STORE = "sessionActor"; +let sessionActorDbPromise = null; function arrayBufferToBase64(buffer) { if (!buffer) { @@ -62,6 +66,91 @@ function base64ToUint8Array(base64) { return bytes; } +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (error) { + return false; + } +} + +function openSessionActorDb() { + if (!isIndexedDbAvailable()) { + return Promise.resolve(null); + } + + if (sessionActorDbPromise) { + return sessionActorDbPromise; + } + + sessionActorDbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(SESSION_ACTOR_DB_NAME, SESSION_ACTOR_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(SESSION_ACTOR_STORE)) { + db.createObjectStore(SESSION_ACTOR_STORE); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open session actor DB")); + }; + }); + + return sessionActorDbPromise; +} + +function persistSessionActorToIndexedDb(payload) { + return openSessionActorDb() + .then((db) => { + if (!db) { + return false; + } + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite"); + const store = tx.objectStore(SESSION_ACTOR_STORE); + store.put(payload, SESSION_ACTOR_STORAGE_KEY); + tx.oncomplete = () => resolve(true); + tx.onerror = () => + reject(tx.error || new Error("Session actor IndexedDB write failed")); + tx.onabort = () => + reject(tx.error || new Error("Session actor IndexedDB write aborted")); + }); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to persist session actor to IndexedDB:", error); + return false; + }); +} + +function clearStoredSessionActorIndexedDb() { + return openSessionActorDb() + .then((db) => { + if (!db) { + return false; + } + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite"); + const store = tx.objectStore(SESSION_ACTOR_STORE); + store.delete(SESSION_ACTOR_STORAGE_KEY); + tx.oncomplete = () => resolve(true); + tx.onerror = () => + reject(tx.error || new Error("Session actor IndexedDB delete failed")); + tx.onabort = () => + reject(tx.error || new Error("Session actor IndexedDB delete aborted")); + }); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to clear session actor IndexedDB entry:", error); + return false; + }); +} + function generateRandomBytes(length) { const size = Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0; if (size <= 0) { @@ -283,8 +372,6 @@ export function readStoredSessionActorEntry() { const parsed = JSON.parse(raw); const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; - const privateKey = - typeof parsed?.privateKey === "string" ? parsed.privateKey.trim() : ""; const privateKeyEncrypted = typeof parsed?.privateKeyEncrypted === "string" ? parsed.privateKeyEncrypted.trim() @@ -294,9 +381,33 @@ export function readStoredSessionActorEntry() { ? parsed.createdAt : Date.now(); + if (parsed?.privateKey) { + if (privateKeyEncrypted && encryption) { + persistSessionActor({ + pubkey, + privateKeyEncrypted, + encryption, + createdAt, + }); + } else { + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear legacy session actor entry:", + cleanupError, + ); + } + } + } + + if (!privateKeyEncrypted || !encryption) { + return null; + } + return { pubkey, - privateKey, + privateKey: "", privateKeyEncrypted, encryption, createdAt, @@ -317,16 +428,14 @@ export function readStoredSessionActorEntry() { } export function persistSessionActor(actor) { - if (typeof localStorage === "undefined") { - return; - } - if ( !actor || typeof actor.pubkey !== "string" || !actor.pubkey || - ((typeof actor.privateKey !== "string" || !actor.privateKey) && - (typeof actor.privateKeyEncrypted !== "string" || !actor.privateKeyEncrypted)) + typeof actor.privateKeyEncrypted !== "string" || + !actor.privateKeyEncrypted || + !actor.encryption || + typeof actor.encryption !== "object" ) { return; } @@ -340,43 +449,36 @@ export function persistSessionActor(actor) { createdAt, }; - if (typeof actor.privateKey === "string" && actor.privateKey) { - payload.privateKey = actor.privateKey; - } else if ( - typeof actor.privateKeyEncrypted === "string" && - actor.privateKeyEncrypted && - actor.encryption && - typeof actor.encryption === "object" - ) { - const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption); - if (!normalizedEncryption) { - return; - } - payload.privateKeyEncrypted = actor.privateKeyEncrypted; - payload.encryption = normalizedEncryption; - } else { + const normalizedEncryption = normalizeStoredEncryptionMetadata(actor.encryption); + if (!normalizedEncryption) { return; } + payload.privateKeyEncrypted = actor.privateKeyEncrypted; + payload.encryption = normalizedEncryption; - try { - localStorage.setItem( - SESSION_ACTOR_STORAGE_KEY, - JSON.stringify(payload), - ); - } catch (error) { - devLogger.warn("[nostr] Failed to persist session actor:", error); + persistSessionActorToIndexedDb(payload); + + if (typeof localStorage !== "undefined") { + try { + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify(payload), + ); + } catch (error) { + devLogger.warn("[nostr] Failed to persist session actor:", error); + } } } export function clearStoredSessionActor() { - if (typeof localStorage === "undefined") { - return; - } + clearStoredSessionActorIndexedDb(); - try { - localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); - } catch (error) { - devLogger.warn("[nostr] Failed to clear stored session actor:", error); + if (typeof localStorage !== "undefined") { + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear stored session actor:", error); + } } } diff --git a/js/nostrClientRegistry.js b/js/nostrClientRegistry.js index 25d21901..6a8ab289 100644 --- a/js/nostrClientRegistry.js +++ b/js/nostrClientRegistry.js @@ -200,6 +200,32 @@ export function getActiveSigner() { return activeSigner; } +export function logoutSigner(pubkey) { + const normalized = normalizePubkey(pubkey); + if (normalized) { + const entry = registeredSigners.get(normalized); + if (entry?.signer && typeof entry.signer.destroy === "function") { + try { + entry.signer.destroy(); + } catch (error) { + // Ignore signer teardown failures. + } + } + registerSigner(normalized, null); + return; + } + + if (activeSigner) { + const activePubkey = normalizePubkey(activeSigner.pubkey || activeSignerPubkey); + if (activePubkey) { + logoutSigner(activePubkey); + return; + } + } + + updateActiveSigner(null, ""); +} + export function resolveActiveSigner(pubkey) { const normalized = normalizePubkey(pubkey); if (normalized) {