diff --git a/js/nostr/client.js b/js/nostr/client.js index 64da3edc..38d8993c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2919,8 +2919,8 @@ export class NostrClient { return this.signerManager.onRemoteSignerChange(listener); } - getStoredNip46Metadata() { - return this.signerManager.getStoredNip46Metadata(); + getStoredNip46Metadata(pubkey) { + return this.signerManager.getStoredNip46Metadata(pubkey); } async ensureExtensionPermissions( diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 84da75f4..6338d4fc 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -778,8 +778,14 @@ export class SignerManager { 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(); + let stored = readStoredNip46Session(targetPubkey || undefined); if (!stored) { const error = new Error( "No remote signer session is stored on this device.", @@ -850,9 +856,10 @@ export class SignerManager { 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. + // 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(); + clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined); } throw err; } @@ -947,8 +954,8 @@ export class SignerManager { this.emitRemoteSignerChange(); } - getStoredNip46Metadata() { - const stored = readStoredNip46Session() || {}; + getStoredNip46Metadata(pubkey) { + const stored = readStoredNip46Session(pubkey) || {}; const userPubkey = typeof stored.userPubkey === "string" ? stored.userPubkey : ""; return { diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index 893d25b5..310e4906 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -46,6 +46,13 @@ import { SHORT_TIMEOUT_MS } from "../constants.js"; export const NIP46_RPC_KIND = 24_133; const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +// Per-user-pubkey remote-signer session store, mirroring the per-account nsec +// key store (bitvid:sessionActors:v2). Lets several saved NIP-46 accounts each +// keep their own reconnectable session so the profile switcher can move between +// them. The v1 single slot above is kept in sync as the last-connected default +// (boot restore) and migrated into this map on first read. +const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2"; +const NIP46_SESSIONS_MAP_VERSION = 2; const NIP46_PUBLISH_TIMEOUT_MS = 8_000; const NIP46_PING_TIMEOUT_MS = SHORT_TIMEOUT_MS; const NIP46_RESPONSE_TIMEOUT_MS = 15_000; @@ -248,7 +255,88 @@ export function sanitizeStoredNip46Session(candidate) { }; } -export function readStoredNip46Session() { +function normalizeNip46PubkeyKey(pubkey) { + return typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; +} + +// Read the per-user-pubkey session map { [userPubkeyLower]: session }. +function readNip46SessionsMap() { + const storage = getNip46Storage(); + if (!storage) { + return {}; + } + let raw = null; + try { + raw = storage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (error) { + return {}; + } + if (!raw) { + 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 session = sanitizeStoredNip46Session(entries[key]); + if (!session) { + continue; + } + const normalizedKey = normalizeNip46PubkeyKey(session.userPubkey || key); + if (normalizedKey) { + map[normalizedKey] = session; + } + } + return map; + } catch (error) { + try { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt NIP-46 session map:", + cleanupError, + ); + } + return {}; + } +} + +function writeNip46SessionsMap(map) { + const storage = getNip46Storage(); + if (!storage) { + return; + } + const entries = {}; + for (const key of Object.keys(map || {})) { + const session = sanitizeStoredNip46Session(map[key]); + if (session) { + entries[key] = session; + } + } + try { + if (!Object.keys(entries).length) { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + return; + } + storage.setItem( + NIP46_SESSIONS_MAP_STORAGE_KEY, + JSON.stringify({ version: NIP46_SESSIONS_MAP_VERSION, entries }), + ); + } catch (error) { + // ignore persistence failures + } +} + +// Read the legacy v1 single slot, re-persisting it in the sanitized (no +// plaintext) shape if it still carries raw credentials. +function readV1Nip46Session() { const storage = getNip46Storage(); if (!storage) { return null; @@ -289,6 +377,49 @@ export function readStoredNip46Session() { } } +// Read a stored remote-signer session. +// readStoredNip46Session(pubkey) → that account's session (or null) +// readStoredNip46Session() → the last-connected account (v1 slot), +// for boot restore / backward-compat. +// The v1 single slot is migrated into the per-pubkey map on first read. +export function readStoredNip46Session(pubkey) { + const v1 = readV1Nip46Session(); + const map = readNip46SessionsMap(); + + // Migrate the legacy single slot into the per-pubkey map. + if (v1 && v1.userPubkey) { + const key = normalizeNip46PubkeyKey(v1.userPubkey); + if (key && !map[key]) { + map[key] = v1; + writeNip46SessionsMap(map); + } + } + + const target = normalizeNip46PubkeyKey(pubkey); + if (target) { + if (map[target]) { + return map[target]; + } + if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) { + return v1; + } + return null; + } + + // No target: the last-connected account. Fall back to the sole stored session + // if the v1 slot was cleared but the map still holds exactly one. + if (v1) { + return v1; + } + const keys = Object.keys(map); + return keys.length === 1 ? map[keys[0]] : null; +} + +// User pubkeys (hex, lowercase) of every account with a stored session. +export function listStoredNip46SessionPubkeys() { + return Object.keys(readNip46SessionsMap()); +} + export function writeStoredNip46SessionSync(payload) { const storage = getNip46Storage(); if (!storage) { @@ -321,6 +452,16 @@ export function writeStoredNip46SessionSync(payload) { return; } + // Store under the account's user pubkey so several saved accounts coexist… + const key = normalizeNip46PubkeyKey(normalized.userPubkey); + if (key) { + const map = readNip46SessionsMap(); + map[key] = normalized; + writeNip46SessionsMap(map); + } + + // …and keep the v1 single slot pointed at the last-connected account so boot + // restore and no-arg reads resolve a sensible default. try { storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized)); } catch (error) { @@ -412,17 +553,45 @@ export async function decryptNip46Session(session, passphrase) { }; } -export function clearStoredNip46Session() { +// Forget a stored session. With a pubkey, only that account is removed (the +// others stay reconnectable); without one, every stored session is wiped +// (logout). +export function clearStoredNip46Session(pubkey) { const storage = getNip46Storage(); if (!storage) { return; } + const target = normalizeNip46PubkeyKey(pubkey); + + if (target) { + const map = readNip46SessionsMap(); + if (map[target]) { + delete map[target]; + writeNip46SessionsMap(map); + } + // Drop the v1 default only if it pointed at the account we just removed. + const v1 = readV1Nip46Session(); + if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } + } + return; + } + try { storage.removeItem(NIP46_SESSION_STORAGE_KEY); } catch (error) { // ignore cleanup issues } + try { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } } export function decodeNpubToHex(npub) { diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js index 3e421c87..ac8a8841 100644 --- a/js/services/authProviders/nip46.js +++ b/js/services/authProviders/nip46.js @@ -97,6 +97,10 @@ function normalizeOptions(options) { handshakeTimeoutMs: Number.isFinite(options.handshakeTimeoutMs) ? Number(options.handshakeTimeoutMs) : undefined, + // Set when switching to a specific saved account, so reusing a stored + // session targets that account rather than the last-connected default. + expectPubkey: + typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "", }; } @@ -163,7 +167,10 @@ export default { // For now, I will assume I will fix client.js later or accept that stored sessions are trusted? // No, I must fix useStoredRemoteSigner too. - result = await nostrClient.useStoredRemoteSigner({ validator }); + result = await nostrClient.useStoredRemoteSigner({ + validator, + pubkey: normalized.expectPubkey || undefined, + }); devLogger.debug("[nip46] Stored remote signer returned", { pubkey: summarizeHexForLog(normalizePubkey(result)), }); diff --git a/tests/nip46-multi-account.test.mjs b/tests/nip46-multi-account.test.mjs new file mode 100644 index 00000000..9d18d896 --- /dev/null +++ b/tests/nip46-multi-account.test.mjs @@ -0,0 +1,127 @@ +// Per-account NIP-46 remote-signer session storage: several saved NIP-46 +// accounts each keep their own reconnectable session so the profile switcher +// can move between them. Before this, a single slot (bitvid:nip46:session:v1) +// meant only the last-connected session survived, so switching among saved +// NIP-46 accounts silently reconnected the wrong one (or failed). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip46-multi-account-storage +// given: "two NIP-46 accounts each persist a stored session" +// when: "the store is read by user pubkey / cleared for one account" +// then: "each account resolves its own session; clearing one keeps the other" +// observable_outcomes: +// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays" +// - "clearStoredNip46Session(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 { + writeStoredNip46SessionSync, + readStoredNip46Session, + clearStoredNip46Session, + listStoredNip46SessionPubkeys, +} from "../js/nostr/nip46Client.js"; + +const USER_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const USER_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; +const REMOTE_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const REMOTE_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2"; + +function session(userPubkey, remotePubkey, relay) { + return { + version: 1, + clientPublicKey: + "3333333333333333333333333333333333333333333333333333333333333333", + remotePubkey, + relays: [relay], + encryption: "nip44.v2", + userPubkey, + lastConnectedAt: 1700000000000, + encryptedSecrets: "cipher", + keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +test("each saved account resolves its own session", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); + assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]); +}); + +test("lists every account that has a stored session", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort()); +}); + +test("clearing one account keeps the others reconnectable", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + clearStoredNip46Session(USER_A); + + assert.equal(readStoredNip46Session(USER_A), null); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); +}); + +test("no-arg read returns the last-connected account as the default", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B); +}); + +test("no-arg clear wipes every stored session (logout)", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + clearStoredNip46Session(); + + assert.deepEqual(listStoredNip46SessionPubkeys(), []); + assert.equal(readStoredNip46Session(USER_A), null); + assert.equal(readStoredNip46Session(), null); +}); + +test("a legacy v1 single-slot session migrates into the per-pubkey map", () => { + // Simulate an install that only ever had the old single slot. + localStorage.setItem( + NIP46_SESSION_STORAGE_KEY, + JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")), + ); + assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null); + + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]); + + // A second account can now be added alongside the migrated one. + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); +}); + +test("requesting an account with no stored session returns null (not a wrong one)", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + assert.equal(readStoredNip46Session(USER_B), null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 2c360cf0..0dc12b4f 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -635,11 +635,20 @@ toward freshness and looked identical. Gave each a structural identity: `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. +- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is + now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`), + mirroring the nsec store. Several saved NIP-46 accounts each keep their own + reconnectable session, so the switcher can move between them. The legacy v1 single + slot is kept in sync as the last-connected default (boot restore) and migrated into + the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an + optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by + `userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one + account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey + })` selects the target account's session and, on access-denial, forgets only that + account; the nsec/nip46 providers thread `expectPubkey` through so a switch + reconnects the requested account (with the `expectPubkey` guard as a backstop). Added + `listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path + is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7). ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no