mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 03:12:31 +00:00
[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:
1 parent
abdd11b18c
commit
8692dac671
6 files changed
+372
-15
No files matched your search
@@ -1380,7 +1380,7 @@ export function createAuthSessionCoordinator(deps) {
|
|||||||
async prepareStoredNsecSwitch(targetPubkey) {
|
async prepareStoredNsecSwitch(targetPubkey) {
|
||||||
const meta =
|
const meta =
|
||||||
typeof nostrClient?.getStoredSessionActorMetadata === "function"
|
typeof nostrClient?.getStoredSessionActorMetadata === "function"
|
||||||
? nostrClient.getStoredSessionActorMetadata()
|
? nostrClient.getStoredSessionActorMetadata(targetPubkey)
|
||||||
: null;
|
: null;
|
||||||
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
|
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
|
||||||
if (decision.action === "error") {
|
if (decision.action === "error") {
|
||||||
|
|||||||
+8
-5
@@ -2597,10 +2597,10 @@ export class NostrClient {
|
|||||||
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
|
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
|
||||||
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
|
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
|
||||||
// flow; without this the unlock UI never appeared.
|
// flow; without this the unlock UI never appeared.
|
||||||
getStoredSessionActorMetadata() {
|
getStoredSessionActorMetadata(pubkey) {
|
||||||
let entry = null;
|
let entry = null;
|
||||||
try {
|
try {
|
||||||
entry = readStoredSessionActorEntry();
|
entry = readStoredSessionActorEntry(pubkey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devLogger.warn(
|
devLogger.warn(
|
||||||
"[nostr] Failed to read stored session actor metadata:",
|
"[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.
|
// passphrase and re-activating the signer via the same path as fresh login.
|
||||||
// The access-control validator is enforced before activation, and a
|
// The access-control validator is enforced before activation, and a
|
||||||
// now-blocked key is forgotten.
|
// now-blocked key is forgotten.
|
||||||
async unlockStoredSessionActor(passphrase, { validator } = {}) {
|
async unlockStoredSessionActor(passphrase, { validator, pubkey: targetPubkey } = {}) {
|
||||||
if (typeof passphrase !== "string" || !passphrase.trim()) {
|
if (typeof passphrase !== "string" || !passphrase.trim()) {
|
||||||
const error = new Error("A passphrase is required to unlock the saved key.");
|
const error = new Error("A passphrase is required to unlock the saved key.");
|
||||||
error.code = "passphrase-required";
|
error.code = "passphrase-required";
|
||||||
throw error;
|
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) {
|
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
|
||||||
const error = new Error("There is no saved key to unlock.");
|
const error = new Error("There is no saved key to unlock.");
|
||||||
error.code = "no-stored-session";
|
error.code = "no-stored-session";
|
||||||
@@ -2646,7 +2648,8 @@ export class NostrClient {
|
|||||||
validator(storedPubkey);
|
validator(storedPubkey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
clearStoredSessionActorEntry();
|
// Forget only the now-blocked account, not every saved key.
|
||||||
|
clearStoredSessionActorEntry(storedPubkey);
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
devLogger.warn(
|
devLogger.warn(
|
||||||
"[nostr] Failed to clear stored session after access denial:",
|
"[nostr] Failed to clear stored session after access denial:",
|
||||||
|
|||||||
+216
-5
@@ -1,6 +1,13 @@
|
|||||||
import { devLogger } from "../utils/logger.js";
|
import { devLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
|
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_ENCRYPTION_VERSION = 1;
|
||||||
export const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
|
export const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
|
||||||
export const SESSION_ACTOR_KDF_HASH = "SHA-256";
|
export const SESSION_ACTOR_KDF_HASH = "SHA-256";
|
||||||
@@ -114,7 +121,9 @@ function persistSessionActorToIndexedDb(payload) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
|
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
|
||||||
const store = tx.objectStore(SESSION_ACTOR_STORE);
|
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.oncomplete = () => resolve(true);
|
||||||
tx.onerror = () =>
|
tx.onerror = () =>
|
||||||
reject(tx.error || new Error("Session actor IndexedDB write failed"));
|
reject(tx.error || new Error("Session actor IndexedDB write failed"));
|
||||||
@@ -128,7 +137,7 @@ function persistSessionActorToIndexedDb(payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStoredSessionActorIndexedDb() {
|
function clearStoredSessionActorIndexedDb(pubkey) {
|
||||||
return openSessionActorDb()
|
return openSessionActorDb()
|
||||||
.then((db) => {
|
.then((db) => {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
@@ -137,7 +146,14 @@ function clearStoredSessionActorIndexedDb() {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
|
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
|
||||||
const store = tx.objectStore(SESSION_ACTOR_STORE);
|
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.oncomplete = () => resolve(true);
|
||||||
tx.onerror = () =>
|
tx.onerror = () =>
|
||||||
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
|
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
|
||||||
@@ -351,7 +367,113 @@ export async function decryptSessionPrivateKey(payload, passphrase) {
|
|||||||
return decoder.decode(decrypted);
|
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") {
|
if (typeof localStorage === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -427,6 +549,50 @@ export function readStoredSessionActorEntry() {
|
|||||||
return null;
|
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) {
|
export function persistSessionActor(actor) {
|
||||||
if (
|
if (
|
||||||
!actor ||
|
!actor ||
|
||||||
@@ -458,6 +624,21 @@ export function persistSessionActor(actor) {
|
|||||||
|
|
||||||
persistSessionActorToIndexedDb(payload);
|
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") {
|
if (typeof localStorage !== "undefined") {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
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();
|
clearStoredSessionActorIndexedDb();
|
||||||
|
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof localStorage !== "undefined") {
|
||||||
@@ -479,6 +685,11 @@ export function clearStoredSessionActor() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
devLogger.warn("[nostr] Failed to clear stored session actor:", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ function normalizeOptions(options) {
|
|||||||
persist: options.persist === true,
|
persist: options.persist === true,
|
||||||
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
|
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
|
||||||
unlockStored: options.unlockStored === true,
|
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(
|
const unlockResult = await nostrClient.unlockStoredSessionActor(
|
||||||
suppliedPassphrase,
|
suppliedPassphrase,
|
||||||
{ validator },
|
{ validator, pubkey: normalized.expectPubkey || undefined },
|
||||||
);
|
);
|
||||||
const unlockedPubkey = normalizePubkey(unlockResult);
|
const unlockedPubkey = normalizePubkey(unlockResult);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
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
|
the extension's active account). Tests in `tests/authService.test.mjs` (option
|
||||||
forwarding + expectPubkey rejection).
|
forwarding + expectPubkey rejection).
|
||||||
- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot
|
- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now
|
||||||
(`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device —
|
per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted,
|
||||||
switching among several saved nsec accounts needs per-account encrypted-key storage.
|
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
|
### 8. Orphan storage garbage-collection tool
|
||||||
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
|
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
|
||||||
|
|||||||
Reference in new issue
Block a user