Files
bitvid/tests/session-actor-multi-account.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 4795d5edbd [auth] Bug pass on login/signer/session surface: 4 fixes before promotion
Requested pre-promotion sweep of login methods, active signers, and anon session
actors. Four real bugs found and fixed:

1. Logging out could wipe EVERY saved account's remembered nsec key. The no-arg
   clearStoredSessionActor() removed the whole per-account v2 map, and it was
   reachable from SignerManager.logout() (non-persisted nsec cleanup) and from
   the blocked-key cleanup in registerPrivateKeySigner — same bug class as the
   NIP-46 session-map wipe fixed earlier. Now: the no-arg form clears only the
   legacy v1 slot; logout targets the departing account (and skips entirely when
   it can't name one); the blocked-key cleanup targets the blocked pubkey
   (mirroring the already-targeted twin in unlockStoredSessionActor).
   Spec-corrected the test that had encoded the wipe-all as intended behavior.

2. Removing a saved profile left its keys on the device. The switcher's
   remove-account flow cleared NWC + the keep-unlocked cache but kept the
   remembered (encrypted) nsec key and the NIP-46 session. New
   client.forgetStoredAccount(pubkey) (SignerManager.forgetStoredSessionActor +
   clearStoredNip46Session) forgets both WITHOUT touching the active session's
   in-memory actors; wired into handleProfileLogoutRequest.

3. Logout never actually re-locked a kept-unlocked device. The forget read
   detail.pubkey / this.pubkey — both already nulled by authService.logout()
   (the detail only carries previousPubkey) — so the cached decrypted key
   silently survived logout. Now reads previousPubkey.

4. restoreUnlockedSigner bypassed access control: a since-blacklisted account's
   cached key would silently restore its signer. It now accepts a validator
   (mirroring unlockStoredSessionActor), forgets the cached key on denial, and
   both app-level call sites (_initAutoLogin, ensureEncryptionCapableSigner)
   pass an accessControl.canAccess gate.

Also verified-safe during the pass (no action): logout's persisted-nsec keep
logic under the ephemeral session-actor overwrite; NIP-46 logout keepStored;
the ephemeral actor as a harmless extra DM-decrypt candidate; corrupt-map
recovery clears; anon blacklist-seeding now always has a signing-capable actor.

Tests: session-actor multi-account suite spec-corrected + re-verified (no-arg
clear keeps per-account keys); 57 tests across session-actor / nip46 /
unlocked-key / switch / signer-gate / feed-gate suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:59:52 -04:00

136 lines
5.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", "spec_correction"]
// 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");
});
// spec_correction: the previous test asserted "no-arg clear wipes every stored
// account (logout)". That encoded a bug: the no-arg clear is reached from
// per-account paths (SignerManager.logout's non-persisted-nsec cleanup, the
// blocked-key cleanup in registerPrivateKeySigner), so logging out ONE account
// silently destroyed every OTHER saved account's remembered key and broke
// switching back to them — the same bug class as the NIP-46 session-map wipe.
// Correct behavior: no-arg clears only the legacy v1 "last saved" slot; the
// per-account v2 map survives. Forgetting a specific account uses the targeted
// clearStoredSessionActor(pubkey) (tested above).
test("no-arg clear drops the legacy v1 slot but KEEPS per-account saved keys", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
clearStoredSessionActor(); // e.g. one account's logout cleanup
// Both accounts remain switchable — their remembered keys survive.
assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort());
assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A");
assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
// The legacy single-slot default is gone.
assert.equal(localStorage.getItem(SESSION_ACTOR_STORAGE_KEY), 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);
});