Files
bitvid/tests/unlocked-key-cache.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 1b17bb8fcc [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51)
After unlocking a saved nsec key the user had to re-enter the PIN on every reload,
because the key is only passphrase-encrypted and the in-memory signer is lost on
refresh. Add an opt-in unlocked-key cache with two tiers (maintainer's choice):

- session (default, always): sessionStorage — survives refresh/navigation, cleared
  when the tab/browser closes. Same rough exposure as the in-memory signer.
- persistent (opt-in): the unlock prompt now shows a "Keep me unlocked on this
  device" checkbox with an inline unencrypted-at-rest warning; when checked the key
  is also written to localStorage and survives until site data is cleared.

client.restoreUnlockedSigner(pubkey) re-registers the signer from the cache WITHOUT
a passphrase — it verifies the cached key derives to that pubkey and forgets a
mismatch, so it never adopts another account. It runs proactively from
ensureActiveSignerForPubkey (auto-login/refresh) and lazily from
ensureEncryptionCapableSigner before any prompt, so after a reload the signer is
restored silently and storage/hashtags/DMs/reactions/blocks/subs all just work.
Logout and remove-saved-profile forget the cached key (forgetUnlockedSigner).

SECURITY: while cached, the key is stored decrypted — the passphrase no longer
protects it at rest. Session tier is the default; persistence is opt-in + warned.

Tests: tests/unlocked-key-cache.test.mjs (8 — session vs persistent tiers, promote
persistent->session on read, reject non-hex, forget one, clear all, missing-storage
degrade). Related unlock/switch/session-actor suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:51:41 -04:00

136 lines
5.3 KiB
JavaScript

// Opt-in "keep unlocked" key cache (TODO #51). Default tier = session (survives
// refresh, cleared on tab close); opt-in persistent tier = localStorage (survives
// until site data is cleared). Reads promote a persistent hit into the session tier.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-unlocked-key-cache
// given: "in-memory session + local storage mocks"
// when: "remember/read/forget/clear run for session vs persistent tiers"
// then: "session-only never touches disk; persistent survives; forget/clear wipe both"
// observable_outcomes:
// - "session remember -> readable, NOT in the persistent store"
// - "persistent remember -> readable AND in the persistent store"
// - "toggling persist:false removes the on-disk copy"
// - "a persistent-only key is promoted back into the session store on read"
// - "invalid (non-hex) keys are rejected"
// - "forget clears one pubkey; clearAll wipes everything"
// determinism_controls:
// - "injected in-memory Storage mocks; no real browser storage"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "leaking real storage between tests"]
// relaxation:
// did_relax_any_assertion: false
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import {
rememberUnlockedKey,
readUnlockedKey,
hasPersistentUnlockedKey,
forgetUnlockedKey,
clearAllUnlockedKeys,
UNLOCKED_KEY_STORE_KEY,
} from "../js/nostr/unlockedKeyCache.js";
function makeStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
_map: m,
};
}
const PUB = "a".repeat(64);
const OTHER = "b".repeat(64);
const KEY = "1".repeat(64);
const KEY2 = "2".repeat(64);
let savedSession;
let savedLocal;
beforeEach(() => {
savedSession = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
savedLocal = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
globalThis.sessionStorage = makeStorage();
globalThis.localStorage = makeStorage();
});
afterEach(() => {
if (savedSession) Object.defineProperty(globalThis, "sessionStorage", savedSession);
else delete globalThis.sessionStorage;
if (savedLocal) Object.defineProperty(globalThis, "localStorage", savedLocal);
else delete globalThis.localStorage;
});
test("session-only remember is readable but never written to disk (localStorage)", () => {
assert.equal(rememberUnlockedKey(PUB, KEY), true);
assert.equal(readUnlockedKey(PUB), KEY);
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
});
test("persistent remember is readable AND written to the persistent store", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(readUnlockedKey(PUB), KEY);
assert.equal(hasPersistentUnlockedKey(PUB), true);
assert.ok(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY));
});
test("re-remembering with persist:false removes the on-disk copy but keeps the session copy", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(hasPersistentUnlockedKey(PUB), true);
rememberUnlockedKey(PUB, KEY, { persist: false });
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(readUnlockedKey(PUB), KEY, "still unlocked for the session");
});
test("a persistent-only key (session cleared, e.g. new tab) is promoted back into the session store on read", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
// Simulate a fresh tab: session store empty, persistent store retained.
globalThis.sessionStorage = makeStorage();
assert.equal(readUnlockedKey(PUB), KEY, "restored from the persistent tier");
// And it was warmed into the session tier.
assert.ok(globalThis.sessionStorage.getItem(UNLOCKED_KEY_STORE_KEY));
});
test("invalid (non-hex) private keys are rejected", () => {
assert.equal(rememberUnlockedKey(PUB, "not-hex"), false);
assert.equal(rememberUnlockedKey(PUB, ""), false);
assert.equal(rememberUnlockedKey("", KEY), false);
assert.equal(readUnlockedKey(PUB), "");
});
test("forget clears one pubkey from both tiers, leaving others intact", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
rememberUnlockedKey(OTHER, KEY2, { persist: true });
forgetUnlockedKey(PUB);
assert.equal(readUnlockedKey(PUB), "");
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(readUnlockedKey(OTHER), KEY2, "other account still unlocked");
});
test("clearAll wipes every cached key from both tiers", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
rememberUnlockedKey(OTHER, KEY2);
clearAllUnlockedKeys();
assert.equal(readUnlockedKey(PUB), "");
assert.equal(readUnlockedKey(OTHER), "");
assert.equal(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
assert.equal(globalThis.sessionStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
});
test("missing storage backends degrade gracefully (no throw)", () => {
delete globalThis.sessionStorage;
delete globalThis.localStorage;
assert.doesNotThrow(() => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(readUnlockedKey(PUB), "");
forgetUnlockedKey(PUB);
clearAllUnlockedKeys();
});
});