mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
124 lines
4.7 KiB
JavaScript
124 lines
4.7 KiB
JavaScript
// 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);
|
|
});
|