Files
bitvid/js/nostr/unlockedKeyCache.js
T
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

185 lines
4.9 KiB
JavaScript

// Opt-in "keep unlocked" cache for a decrypted nsec key so the user isn't
// re-prompted for their PIN after every page reload (TODO #51). SECURITY TRADEOFF:
// while an unlocked key is cached it is stored DECRYPTED — the passphrase no longer
// protects it at rest. Two tiers, chosen by the user at unlock time:
// - session (default): sessionStorage — survives refresh/navigation, auto-cleared
// when the tab/browser closes. Roughly the same exposure as the in-memory signer.
// - persistent (opt-in checkbox + one-time warning): also localStorage — survives
// until the user clears site data. Convenient, but the key sits on disk.
//
// readUnlockedKey transparently promotes a persistent copy back into the session
// store so subsequent reads are cheap. Everything is keyed per pubkey.
const STORE_KEY = "bitvid:unlockedKeys:v1";
const HEX64 = /^[0-9a-f]{64}$/;
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
}
function getStore(kind) {
try {
const store =
kind === "session"
? globalThis.sessionStorage
: globalThis.localStorage;
if (
store &&
typeof store.getItem === "function" &&
typeof store.setItem === "function" &&
typeof store.removeItem === "function"
) {
return store;
}
} catch (error) {
// Access can throw (privacy mode, disabled storage) — treat as unavailable.
}
return null;
}
function readMap(store) {
if (!store) {
return {};
}
try {
const raw = store.getItem(STORE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function writeMap(store, map) {
if (!store) {
return;
}
try {
const keys = Object.keys(map || {});
if (!keys.length) {
store.removeItem(STORE_KEY);
return;
}
store.setItem(STORE_KEY, JSON.stringify(map));
} catch (error) {
// Quota / disabled storage — best-effort only.
}
}
/**
* Cache a decrypted nsec key so it can be restored without the passphrase.
* Always writes the session tier; the persistent tier is written only when
* `persist` is true (and any stale persistent copy is removed otherwise).
*
* @returns {boolean} true if a valid key was cached.
*/
export function rememberUnlockedKey(pubkey, privateKeyHex, { persist = false } = {}) {
const key = normalizePubkey(pubkey);
const value =
typeof privateKeyHex === "string" ? privateKeyHex.trim().toLowerCase() : "";
if (!key || !HEX64.test(value)) {
return false;
}
const session = getStore("session");
if (session) {
const map = readMap(session);
map[key] = value;
writeMap(session, map);
}
const local = getStore("local");
if (local) {
const map = readMap(local);
if (persist) {
map[key] = value;
} else {
delete map[key];
}
writeMap(local, map);
}
return true;
}
/**
* Read a cached decrypted key for a pubkey (session first, then persistent). A
* persistent hit is promoted back into the session store.
*
* @returns {string} the hex private key, or "" if none is cached.
*/
export function readUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return "";
}
const session = getStore("session");
const sessionValue = readMap(session)[key];
if (typeof sessionValue === "string" && HEX64.test(sessionValue)) {
return sessionValue;
}
const local = getStore("local");
const localValue = readMap(local)[key];
if (typeof localValue === "string" && HEX64.test(localValue)) {
// Warm the session tier so later reads don't touch the persistent store.
if (session) {
const map = readMap(session);
map[key] = localValue;
writeMap(session, map);
}
return localValue;
}
return "";
}
/** True if this pubkey has a persistent (on-disk) cached key. */
export function hasPersistentUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return false;
}
const value = readMap(getStore("local"))[key];
return typeof value === "string" && HEX64.test(value);
}
/** Forget the cached key for one pubkey (both tiers). */
export function forgetUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return;
}
for (const kind of ["session", "local"]) {
const store = getStore(kind);
if (!store) {
continue;
}
const map = readMap(store);
if (key in map) {
delete map[key];
writeMap(store, map);
}
}
}
/** Forget every cached key (both tiers). Used on full logout / lock-all. */
export function clearAllUnlockedKeys() {
for (const kind of ["session", "local"]) {
const store = getStore(kind);
if (store) {
try {
store.removeItem(STORE_KEY);
} catch (error) {
// best-effort
}
}
}
}
export const UNLOCKED_KEY_STORE_KEY = STORE_KEY;