mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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]>
136 lines
5.7 KiB
JavaScript
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);
|
|
});
|