[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:<thing>:<npub> 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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 22:33:03 -04:00
1 parent abdd11b18c
commit 8692dac671
6 files changed
+372 -15

No files matched your search

+1 -1
View File
@@ -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") {
+8 -5
View File
@@ -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:",
+216 -5
View File
@@ -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:<thing>:<npub>`
// 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);
}
}
}
+5 -1
View File
@@ -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);
+123
View File
@@ -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);
});
+19 -3
View File
@@ -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:<thing>:<npub>`
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