From 8692dac6715f39065d193487857b9d6a091e6b3f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:33:03 -0400 Subject: [PATCH] [nostr-core] Per-account nsec key storage for multi-account switching (#48) Move the encrypted-key store from a single slot to a per-pubkey map so several saved nsec accounts each keep their own key on the device and the profile switcher can move between them. Previously bitvid:sessionActor:v1 held one key (last-saved wins), so switching among saved nsec accounts silently failed. - sessionActor.js: new bitvid:sessionActors:v2 map keyed by pubkey (follows the existing bitvid:: convention). readStoredSessionActorEntry, persistSessionActor, clearStoredSessionActor take an optional target pubkey; legacy v1 slot is kept in sync as the last-saved default and migrated into the map on first read. IndexedDB mirror keyed per-pubkey. Adds listStoredSessionActorPubkeys(). - client.js: getStoredSessionActorMetadata / unlockStoredSessionActor accept a target pubkey; access-denial cleanup forgets only the blocked account. - nsec provider threads expectPubkey into the unlock so a switch unlocks the requested account, with the expectPubkey guard as a backstop. - authSessionCoordinator: look up the target account's stored-key metadata. NIP-46 multi-account (single slot bitvid:nip46:session:v1) remains as a follow-up. Tests: tests/session-actor-multi-account.test.mjs (7). Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 2 +- js/nostr/client.js | 13 +- js/nostr/sessionActor.js | 221 ++++++++++++++++++++- js/services/authProviders/nsec.js | 6 +- tests/session-actor-multi-account.test.mjs | 123 ++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 22 +- 6 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 tests/session-actor-multi-account.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 0ba298eb..f6511407 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -1380,7 +1380,7 @@ export function createAuthSessionCoordinator(deps) { async prepareStoredNsecSwitch(targetPubkey) { const meta = typeof nostrClient?.getStoredSessionActorMetadata === "function" - ? nostrClient.getStoredSessionActorMetadata() + ? nostrClient.getStoredSessionActorMetadata(targetPubkey) : null; const decision = evaluateStoredNsecSwitch(meta, targetPubkey); if (decision.action === "error") { diff --git a/js/nostr/client.js b/js/nostr/client.js index 053639da..64da3edc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2597,10 +2597,10 @@ export class NostrClient { // Metadata about a stored, passphrase-encrypted nsec session (no secrets). // The login modal reads `hasEncryptedKey` to offer the "unlock saved key" // flow; without this the unlock UI never appeared. - getStoredSessionActorMetadata() { + getStoredSessionActorMetadata(pubkey) { let entry = null; try { - entry = readStoredSessionActorEntry(); + entry = readStoredSessionActorEntry(pubkey); } catch (error) { devLogger.warn( "[nostr] Failed to read stored session actor metadata:", @@ -2623,14 +2623,16 @@ export class NostrClient { // passphrase and re-activating the signer via the same path as fresh login. // The access-control validator is enforced before activation, and a // now-blocked key is forgotten. - async unlockStoredSessionActor(passphrase, { validator } = {}) { + async unlockStoredSessionActor(passphrase, { validator, pubkey: targetPubkey } = {}) { if (typeof passphrase !== "string" || !passphrase.trim()) { const error = new Error("A passphrase is required to unlock the saved key."); error.code = "passphrase-required"; throw error; } - const entry = readStoredSessionActorEntry(); + // When switching accounts, `targetPubkey` selects that account's stored key + // so we unlock the one the user asked for — not just the last-saved default. + const entry = readStoredSessionActorEntry(targetPubkey); if (!entry || !entry.privateKeyEncrypted || !entry.encryption) { const error = new Error("There is no saved key to unlock."); error.code = "no-stored-session"; @@ -2646,7 +2648,8 @@ export class NostrClient { validator(storedPubkey); } catch (error) { try { - clearStoredSessionActorEntry(); + // Forget only the now-blocked account, not every saved key. + clearStoredSessionActorEntry(storedPubkey); } catch (cleanupError) { devLogger.warn( "[nostr] Failed to clear stored session after access denial:", diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js index 937a087f..18b98b99 100644 --- a/js/nostr/sessionActor.js +++ b/js/nostr/sessionActor.js @@ -1,6 +1,13 @@ import { devLogger } from "../utils/logger.js"; export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; +// Per-pubkey encrypted-key store. Follows the existing `bitvid::` +// convention (profileCache, nwcSettings, …) so several saved nsec accounts can +// each keep their own encrypted key on the device and be switched between. The +// v1 single slot above is kept in sync as the "active/last-saved" default and is +// migrated into this map on first read. +export const SESSION_ACTORS_MAP_STORAGE_KEY = "bitvid:sessionActors:v2"; +export const SESSION_ACTORS_MAP_VERSION = 2; export const SESSION_ACTOR_ENCRYPTION_VERSION = 1; export const SESSION_ACTOR_KDF_ITERATIONS = 250_000; export const SESSION_ACTOR_KDF_HASH = "SHA-256"; @@ -114,7 +121,9 @@ function persistSessionActorToIndexedDb(payload) { 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); + // Key each account's payload by pubkey so multiple saved accounts don't + // clobber one another (legacy single-slot records used the storage key). + store.put(payload, normalizePubkeyKey(payload?.pubkey) || SESSION_ACTOR_STORAGE_KEY); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("Session actor IndexedDB write failed")); @@ -128,7 +137,7 @@ function persistSessionActorToIndexedDb(payload) { }); } -function clearStoredSessionActorIndexedDb() { +function clearStoredSessionActorIndexedDb(pubkey) { return openSessionActorDb() .then((db) => { if (!db) { @@ -137,7 +146,14 @@ function clearStoredSessionActorIndexedDb() { 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); + const key = normalizePubkeyKey(pubkey); + if (key) { + // Forget just this account's key… + store.delete(key); + } else { + // …or wipe everything (logout / legacy single-slot record too). + store.clear(); + } tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("Session actor IndexedDB delete failed")); @@ -351,7 +367,113 @@ export async function decryptSessionPrivateKey(payload, passphrase) { return decoder.decode(decrypted); } -export function readStoredSessionActorEntry() { +function normalizePubkeyKey(pubkey) { + return typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; +} + +// Turn a parsed stored payload into a canonical entry, or null if it lacks a +// usable encrypted key. Never exposes plaintext (privateKey is always ""). +function normalizeStoredActorEntry(parsed) { + const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; + const privateKeyEncrypted = + typeof parsed?.privateKeyEncrypted === "string" + ? parsed.privateKeyEncrypted.trim() + : ""; + const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption); + const createdAt = Number.isFinite(parsed?.createdAt) + ? parsed.createdAt + : Date.now(); + if (!privateKeyEncrypted || !encryption) { + return null; + } + return { pubkey, privateKey: "", privateKeyEncrypted, encryption, createdAt }; +} + +// Read the per-pubkey map { [pubkeyLower]: entry } from localStorage. +function readSessionActorsMap() { + if (typeof localStorage === "undefined") { + return {}; + } + let raw = null; + try { + raw = localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to read session actor map:", error); + return {}; + } + if (!raw || typeof raw !== "string") { + return {}; + } + try { + const parsed = JSON.parse(raw); + const entries = + parsed && typeof parsed === "object" ? parsed.entries : null; + if (!entries || typeof entries !== "object") { + return {}; + } + const map = {}; + for (const key of Object.keys(entries)) { + const entry = normalizeStoredActorEntry(entries[key]); + if (!entry) { + continue; + } + const normalizedKey = normalizePubkeyKey(entry.pubkey || key); + if (normalizedKey) { + map[normalizedKey] = entry; + } + } + return map; + } catch (error) { + devLogger.warn("[nostr] Failed to parse session actor map:", error); + try { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt session actor map:", + cleanupError, + ); + } + return {}; + } +} + +function writeSessionActorsMap(map) { + if (typeof localStorage === "undefined") { + return; + } + const entries = {}; + for (const key of Object.keys(map || {})) { + const entry = map[key]; + if (!entry || !entry.privateKeyEncrypted || !entry.encryption) { + continue; + } + entries[key] = { + pubkey: entry.pubkey || key, + privateKeyEncrypted: entry.privateKeyEncrypted, + encryption: entry.encryption, + createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(), + }; + } + try { + if (!Object.keys(entries).length) { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + return; + } + localStorage.setItem( + SESSION_ACTORS_MAP_STORAGE_KEY, + JSON.stringify({ version: SESSION_ACTORS_MAP_VERSION, entries }), + ); + } catch (error) { + devLogger.warn("[nostr] Failed to persist session actor map:", error); + } +} + +// Read the legacy single-slot (v1) entry. Also handles the legacy plaintext +// migration: a payload that still carries `privateKey` is re-persisted in the +// encrypted-only shape, or cleared if it can't be salvaged. +function readV1Entry() { if (typeof localStorage === "undefined") { return null; } @@ -427,6 +549,50 @@ export function readStoredSessionActorEntry() { return null; } +// Read a stored, passphrase-encrypted nsec entry. +// readStoredSessionActorEntry(pubkey) → that specific account's entry (or null) +// readStoredSessionActorEntry() → the active/last-saved account (v1 slot), +// for boot restore / backward-compat. +// The v1 single slot is migrated into the per-pubkey map on first read so +// existing single-account installs keep working and gain multi-account storage. +export function readStoredSessionActorEntry(pubkey) { + const v1 = readV1Entry(); + const map = readSessionActorsMap(); + + // Migrate the legacy single slot into the per-pubkey map. + if (v1 && v1.pubkey) { + const key = normalizePubkeyKey(v1.pubkey); + if (key && !map[key]) { + map[key] = v1; + writeSessionActorsMap(map); + } + } + + const target = normalizePubkeyKey(pubkey); + if (target) { + if (map[target]) { + return map[target]; + } + if (v1 && normalizePubkeyKey(v1.pubkey) === target) { + return v1; + } + return null; + } + + // No target: the active/last-saved account (v1 slot). Fall back to the sole + // stored account if the v1 slot was cleared but the map still has exactly one. + if (v1) { + return v1; + } + const keys = Object.keys(map); + return keys.length === 1 ? map[keys[0]] : null; +} + +// Pubkeys (hex, lowercase) of every account with an encrypted key on this device. +export function listStoredSessionActorPubkeys() { + return Object.keys(readSessionActorsMap()); +} + export function persistSessionActor(actor) { if ( !actor || @@ -458,6 +624,21 @@ export function persistSessionActor(actor) { persistSessionActorToIndexedDb(payload); + // Store under the account's pubkey so several saved accounts coexist… + const key = normalizePubkeyKey(payload.pubkey); + if (key) { + const map = readSessionActorsMap(); + map[key] = { + pubkey: payload.pubkey, + privateKeyEncrypted: payload.privateKeyEncrypted, + encryption: payload.encryption, + createdAt, + }; + writeSessionActorsMap(map); + } + + // …and keep the v1 single slot pointed at the last-saved account so boot + // restore and no-arg reads resolve a sensible default. if (typeof localStorage !== "undefined") { try { localStorage.setItem( @@ -470,7 +651,32 @@ export function persistSessionActor(actor) { } } -export function clearStoredSessionActor() { +// Forget a stored key. With a pubkey, only that account is removed (the others +// stay switchable); without one, every stored account is wiped (logout). +export function clearStoredSessionActor(pubkey) { + const target = normalizePubkeyKey(pubkey); + + if (target) { + clearStoredSessionActorIndexedDb(target); + const map = readSessionActorsMap(); + if (map[target]) { + delete map[target]; + writeSessionActorsMap(map); + } + // Drop the v1 default only if it pointed at the account we just removed. + if (typeof localStorage !== "undefined") { + const v1 = readV1Entry(); + if (v1 && normalizePubkeyKey(v1.pubkey) === target) { + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear stored session actor:", error); + } + } + } + return; + } + clearStoredSessionActorIndexedDb(); if (typeof localStorage !== "undefined") { @@ -479,6 +685,11 @@ export function clearStoredSessionActor() { } catch (error) { devLogger.warn("[nostr] Failed to clear stored session actor:", error); } + try { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear session actor map:", error); + } } } diff --git a/js/services/authProviders/nsec.js b/js/services/authProviders/nsec.js index 705005ca..5b1aacbc 100644 --- a/js/services/authProviders/nsec.js +++ b/js/services/authProviders/nsec.js @@ -60,6 +60,10 @@ function normalizeOptions(options) { persist: options.persist === true, passphrase: typeof options.passphrase === "string" ? options.passphrase : "", unlockStored: options.unlockStored === true, + // Set when switching to a specific saved account, so the stored-key unlock + // targets that account rather than the last-saved default. + expectPubkey: + typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "", }; } @@ -120,7 +124,7 @@ export default { const unlockResult = await nostrClient.unlockStoredSessionActor( suppliedPassphrase, - { validator }, + { validator, pubkey: normalized.expectPubkey || undefined }, ); const unlockedPubkey = normalizePubkey(unlockResult); diff --git a/tests/session-actor-multi-account.test.mjs b/tests/session-actor-multi-account.test.mjs new file mode 100644 index 00000000..a54cf89c --- /dev/null +++ b/tests/session-actor-multi-account.test.mjs @@ -0,0 +1,123 @@ +// Per-account nsec key storage: several saved accounts each keep their own +// encrypted key on the device so the profile switcher can move between them. +// Before this, a single slot (bitvid:sessionActor:v1) meant only the last-saved +// key survived, so switching among saved nsec accounts silently failed. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nsec-multi-account-storage +// given: "two nsec accounts each persist their encrypted key" +// when: "the store is read by a specific pubkey / cleared for one account" +// then: "each account resolves its own key; clearing one keeps the other" +// observable_outcomes: +// - "readStoredSessionActorEntry(pubkey) returns that account's ciphertext" +// - "clearStoredSessionActor(pubkey) removes only the named account" +// - "the legacy v1 single slot migrates into the per-pubkey map on read" +// determinism_controls: +// - "in-memory localStorage polyfill; no network/clock dependence" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { + persistSessionActor, + readStoredSessionActorEntry, + clearStoredSessionActor, + listStoredSessionActorPubkeys, + SESSION_ACTOR_STORAGE_KEY, + SESSION_ACTORS_MAP_STORAGE_KEY, +} from "../js/nostr/sessionActor.js"; + +const PUB_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const PUB_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; + +function actor(pubkey, cipher) { + return { + pubkey, + privateKeyEncrypted: cipher, + encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + createdAt: 1700000000000, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +test("each saved account resolves its own encrypted key", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A"); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); + // Never leak plaintext through the read path. + assert.equal(readStoredSessionActorEntry(PUB_A).privateKey, ""); +}); + +test("lists every account that has a stored key", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort()); +}); + +test("clearing one account keeps the others switchable", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + clearStoredSessionActor(PUB_A); + + assert.equal(readStoredSessionActorEntry(PUB_A), null); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); +}); + +test("no-arg read returns the last-saved account as the default", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B"); +}); + +test("no-arg clear wipes every stored account (logout)", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + clearStoredSessionActor(); + + assert.deepEqual(listStoredSessionActorPubkeys(), []); + assert.equal(readStoredSessionActorEntry(PUB_A), null); + assert.equal(readStoredSessionActorEntry(), null); +}); + +test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => { + // Simulate an install that only ever had the old single slot. + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify({ + pubkey: PUB_A, + privateKeyEncrypted: "legacy-cipher", + encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + createdAt: 1699999999000, + }), + ); + assert.equal(localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY), null); + + // Reading by the account's pubkey works and seeds the v2 map. + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher"); + assert.deepEqual(listStoredSessionActorPubkeys(), [PUB_A]); + + // A second account can now be added alongside the migrated one. + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher"); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); +}); + +test("requesting an account with no stored key returns null (not a wrong one)", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + assert.equal(readStoredSessionActorEntry(PUB_B), null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index b201aaa6..2c360cf0 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -621,9 +621,25 @@ toward freshness and looked identical. Gave each a structural identity: to an account the extension isn't currently on now fails clearly instead of grabbing the extension's active account). Tests in `tests/authService.test.mjs` (option forwarding + expectPubkey rejection). -- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot - (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device — - switching among several saved nsec accounts needs per-account encrypted-key storage. +- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now + per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted, + encryption, createdAt } }`), following the existing `bitvid::` + convention (profileCache, nwcSettings, …). Several saved nsec accounts each keep + their own key on the device, so the switcher can move between them. The legacy v1 + single slot is kept in sync as the "last-saved default" (boot restore / no-arg + reads) and is migrated into the map on first read. `readStoredSessionActorEntry`, + `getStoredSessionActorMetadata`, and `unlockStoredSessionActor` all take an optional + target pubkey; the nsec provider threads `expectPubkey` through so a switch unlocks + the requested account (not the last-saved one), with the `expectPubkey` guard as a + backstop. IndexedDB mirror is now keyed per-pubkey too. Added + `listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs` + (7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe, + v1→v2 migration, missing-account returns null). +- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a + SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is + restorable — switching among several saved NIP-46 accounts needs the same per-pubkey + treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey + + `useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec. ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no