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