mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +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) {
|
||||
const meta =
|
||||
typeof nostrClient?.getStoredSessionActorMetadata === "function"
|
||||
? nostrClient.getStoredSessionActorMetadata()
|
||||
? nostrClient.getStoredSessionActorMetadata(targetPubkey)
|
||||
: null;
|
||||
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
|
||||
if (decision.action === "error") {
|
||||
|
||||
+8
-5
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user