From 1b17bb8fcc4fd626215596503bfdf12f50f289f4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:51:41 -0400 Subject: [PATCH] [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/app.js | 31 ++++- js/app/authSessionCoordinator.js | 27 +++++ js/nostr/client.js | 111 ++++++++++++++++- js/nostr/unlockedKeyCache.js | 184 +++++++++++++++++++++++++++++ js/ui/promptDialog.js | 45 ++++++- tests/unlocked-key-cache.test.mjs | 135 +++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 34 ++++-- 7 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 js/nostr/unlockedKeyCache.js create mode 100644 tests/unlocked-key-cache.test.mjs diff --git a/js/app.js b/js/app.js index 1b727d23..88c6809d 100644 --- a/js/app.js +++ b/js/app.js @@ -2122,6 +2122,26 @@ class Application { ); } + // Try a cached "keep unlocked" key first so we never prompt after a reload + // when the user opted to stay unlocked (TODO #51). + if ( + normalized && + typeof nostrClient?.restoreUnlockedSigner === "function" && + !isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need) + ) { + try { + const restored = await nostrClient.restoreUnlockedSigner(normalized); + if ( + restored?.restored && + isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need) + ) { + return { ok: true, restored: true }; + } + } catch (error) { + devLogger.warn("[Application] Cached unlock restore failed:", error); + } + } + const decision = decideSignerEnsure({ capabilities: resolveSignerCapabilities(getActiveSigner()), need, @@ -2141,10 +2161,18 @@ class Application { typeof promptMessage === "string" && promptMessage.trim() ? promptMessage.trim() : "Re-enter your PIN / passphrase to unlock your saved key for this action."; - const passphrase = await showPasswordPrompt(message, { + const promptResult = await showPasswordPrompt(message, { title: "Unlock your key", confirmLabel: "Unlock", + collectRemember: true, }); + if (promptResult == null) { + return { ok: false, reason: "cancelled" }; + } + const passphrase = + typeof promptResult === "string" ? promptResult : promptResult.passphrase; + const persistUnlock = + typeof promptResult === "object" ? Boolean(promptResult.remember) : false; if (passphrase == null || passphrase === "") { return { ok: false, reason: "cancelled" }; } @@ -2152,6 +2180,7 @@ class Application { try { await nostrClient.unlockStoredSessionActor(passphrase, { pubkey: normalized, + persistUnlock, }); } catch (error) { if (error?.code === "decrypt-failed") { diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 2645f35d..6bb442fa 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -973,6 +973,21 @@ export function createAuthSessionCoordinator(deps) { clearDecryptionSchemeCache(); clearWatchHistoryConversationKeyCache(); clearWatchHistoryDecryptedChunkCache(); + + // Forget any cached "keep unlocked" key for the account being logged out, so + // logout actually re-locks it (TODO #51). + try { + const loggedOutPubkey = detail?.pubkey || this.pubkey; + if ( + loggedOutPubkey && + typeof nostrClient.forgetUnlockedSigner === "function" + ) { + nostrClient.forgetUnlockedSigner(loggedOutPubkey); + } + } catch (error) { + devLogger.warn("Failed to forget cached unlock during logout:", error); + } + this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { @@ -1672,6 +1687,18 @@ export function createAuthSessionCoordinator(deps) { } } + // Removing a saved profile must also forget its cached "keep unlocked" key. + try { + if (typeof nostrClient.forgetUnlockedSigner === "function") { + nostrClient.forgetUnlockedSigner(normalizedTarget); + } + } catch (error) { + devLogger.warn( + "[Application] Failed to forget cached unlock for removed profile:", + error, + ); + } + this.renderSavedProfiles(); return { loggedOut: true, removed: true }; diff --git a/js/nostr/client.js b/js/nostr/client.js index c1452801..202dc564 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -174,6 +174,11 @@ import { isSessionActor, } from "./sessionActor.js"; import { HEX64_REGEX, normalizeHexHash, bytesToHex } from "../utils/hex.js"; +import { + rememberUnlockedKey, + readUnlockedKey, + forgetUnlockedKey, +} from "./unlockedKeyCache.js"; import { NIP46_RPC_KIND, NIP46_HANDSHAKE_TIMEOUT_MS, @@ -2623,7 +2628,10 @@ export class NostrClient { // passphrase and re-activating the signer via the same path as fresh login. // The access-control validator is enforced before activation, and a // now-blocked key is forgotten. - async unlockStoredSessionActor(passphrase, { validator, pubkey: targetPubkey } = {}) { + async unlockStoredSessionActor( + passphrase, + { validator, pubkey: targetPubkey, cacheUnlock = true, persistUnlock = false } = {}, + ) { if (typeof passphrase !== "string" || !passphrase.trim()) { const error = new Error("A passphrase is required to unlock the saved key."); error.code = "passphrase-required"; @@ -2710,9 +2718,89 @@ export class NostrClient { (adapter && typeof adapter.pubkey === "string" && adapter.pubkey) || pubkey || ""; + + // Cache the decrypted key so a reload doesn't require the passphrase again + // (TODO #51). Session tier is always written (survives refresh, cleared on tab + // close); the persistent tier (on disk) only when the user opted in. + if (cacheUnlock && resolvedPubkey) { + try { + rememberUnlockedKey(resolvedPubkey, normalizedPrivateKey, { + persist: Boolean(persistUnlock), + }); + } catch (error) { + devLogger.warn("[nostr] Failed to cache unlocked key:", error); + } + } + return { pubkey: resolvedPubkey }; } + // Restore an nsec signer from the "keep unlocked" cache without a passphrase + // (TODO #51). Reads the cached decrypted key for `targetPubkey`, verifies it + // derives to that pubkey (defense-in-depth; a mismatch forgets the cache), and + // registers it app-wide. Returns { restored: boolean }. + async restoreUnlockedSigner(targetPubkey) { + const pubkey = + typeof targetPubkey === "string" && HEX64_REGEX.test(targetPubkey.trim().toLowerCase()) + ? targetPubkey.trim().toLowerCase() + : ""; + if (!pubkey) { + return { restored: false, reason: "no-pubkey" }; + } + + const privateKeyHex = readUnlockedKey(pubkey); + if (!HEX64_REGEX.test(privateKeyHex || "")) { + return { restored: false, reason: "no-cache" }; + } + + let derivedPubkey = ""; + try { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (tools && typeof tools.getPublicKey === "function") { + derivedPubkey = String(tools.getPublicKey(privateKeyHex) || "").toLowerCase(); + } + } catch (error) { + devLogger.warn( + "[nostr] Failed to derive pubkey while restoring unlocked signer:", + error, + ); + } + if (derivedPubkey && derivedPubkey !== pubkey) { + // Corrupt / mismatched cache — never adopt a different account's key. + forgetUnlockedKey(pubkey); + return { restored: false, reason: "pubkey-mismatch" }; + } + + try { + const adapter = await this.registerPrivateKeySigner({ + privateKey: privateKeyHex, + pubkey, + persist: false, + }); + if (this.sessionActor && typeof this.sessionActor === "object") { + this.sessionActor.persisted = true; + } + return { + restored: true, + pubkey: + (adapter && typeof adapter.pubkey === "string" && adapter.pubkey) || + pubkey, + }; + } catch (error) { + devLogger.warn("[nostr] Failed to restore unlocked signer:", error); + return { restored: false, reason: "register-failed", error }; + } + } + + // Forget any cached "keep unlocked" key for an account (both tiers). + forgetUnlockedSigner(targetPubkey) { + try { + forgetUnlockedKey(targetPubkey); + } catch (error) { + devLogger.warn("[nostr] Failed to forget unlocked key:", error); + } + } + /** * Publishes a fresh Video Series (Kind 30078) to the network. * This is the entry point for creating new content. @@ -2866,6 +2954,27 @@ export class NostrClient { } async ensureActiveSignerForPubkey(pubkey) { + // If the registry has no sign-capable signer (e.g. a reload restored the + // pubkey/UI but not the in-memory signer) and a "keep unlocked" nsec key is + // cached, restore it before falling back to the extension path — so a reload + // doesn't require the passphrase (TODO #51). Never adopts a different account + // (restoreUnlockedSigner verifies the derived pubkey). + const existing = getActiveSigner(); + if (!existing || typeof existing.signEvent !== "function") { + const normalized = + typeof pubkey === "string" && HEX64_REGEX.test(pubkey.trim().toLowerCase()) + ? pubkey.trim().toLowerCase() + : ""; + if (normalized && readUnlockedKey(normalized)) { + const restored = await this.restoreUnlockedSigner(normalized); + if (restored?.restored) { + const signer = getActiveSigner(); + if (signer && typeof signer.signEvent === "function") { + return signer; + } + } + } + } return this.signerManager.ensureActiveSignerForPubkey(pubkey); } diff --git a/js/nostr/unlockedKeyCache.js b/js/nostr/unlockedKeyCache.js new file mode 100644 index 00000000..e318db41 --- /dev/null +++ b/js/nostr/unlockedKeyCache.js @@ -0,0 +1,184 @@ +// 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; diff --git a/js/ui/promptDialog.js b/js/ui/promptDialog.js index 33d6c171..6504038b 100644 --- a/js/ui/promptDialog.js +++ b/js/ui/promptDialog.js @@ -4,12 +4,21 @@ // user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above // other modals (and is typable thanks to the stacked-modal focus fix). +// When `collectRemember` is set, an opt-in "keep unlocked" checkbox is shown and +// the prompt resolves `{ passphrase, remember }` instead of a bare string (cancel +// still resolves null). Callers that don't pass `collectRemember` keep the legacy +// string resolution. export function showPasswordPrompt(message, options = {}) { const { title = "", confirmLabel = "Unlock", cancelLabel = "Cancel", placeholder = "PIN / passphrase", + collectRemember = false, + rememberLabel = "Keep me unlocked on this device", + rememberHint = + "Stored unencrypted on this device until you clear site data. Anyone with access to this browser can use it. Leave off to re-enter after closing the tab.", + rememberDefault = false, } = options || {}; if (typeof document === "undefined" || !document.body) { @@ -57,6 +66,32 @@ export function showPasswordPrompt(message, options = {}) { input.autocomplete = "off"; input.setAttribute("aria-label", placeholder); body.appendChild(input); + + let rememberCheckbox = null; + if (collectRemember) { + const rememberWrap = document.createElement("label"); + rememberWrap.className = "flex items-start gap-2 cursor-pointer"; + rememberCheckbox = document.createElement("input"); + rememberCheckbox.type = "checkbox"; + rememberCheckbox.className = "mt-1"; + rememberCheckbox.checked = Boolean(rememberDefault); + const rememberText = document.createElement("span"); + rememberText.className = "text-sm text-muted-strong"; + const rememberTitle = document.createElement("span"); + rememberTitle.className = "block text-text"; + rememberTitle.textContent = rememberLabel; + rememberText.appendChild(rememberTitle); + if (rememberHint) { + const hint = document.createElement("span"); + hint.className = "block text-xs text-status-danger"; + hint.textContent = rememberHint; + rememberText.appendChild(hint); + } + rememberWrap.appendChild(rememberCheckbox); + rememberWrap.appendChild(rememberText); + body.appendChild(rememberWrap); + } + sheet.appendChild(body); const footer = document.createElement("div"); @@ -84,7 +119,15 @@ export function showPasswordPrompt(message, options = {}) { overlay.remove(); resolve(result); }; - const submit = () => cleanup(input.value); + const submit = () => + cleanup( + collectRemember + ? { + passphrase: input.value, + remember: Boolean(rememberCheckbox && rememberCheckbox.checked), + } + : input.value, + ); function onKeydown(event) { if (event.key === "Escape") { event.preventDefault(); diff --git a/tests/unlocked-key-cache.test.mjs b/tests/unlocked-key-cache.test.mjs new file mode 100644 index 00000000..af1fda23 --- /dev/null +++ b/tests/unlocked-key-cache.test.mjs @@ -0,0 +1,135 @@ +// 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(); + }); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 72114f61..bef87cdc 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1573,14 +1573,32 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. - [ ] **Root cause:** same as **#36** — the nsec signer + unlocked storage master key live only in memory, so a reload drops them. Today the fix (#36) makes the error *actionable* (re-enter passphrase), but the user still has to do it every reload. -- [ ] **Ask:** persist/cache the unlocked state so a refresh doesn't require re-entry. - **SECURITY-SENSITIVE:** caching an unlocked master key (or the decrypted nsec) - across reloads weakens the passphrase-at-rest guarantee. Options to weigh: - (a) keep the unlocked key in `sessionStorage` (survives reload, cleared on tab - close) vs (b) an opt-in "keep unlocked on this device for N minutes/until logout" - with a clear warning, vs (c) status-quo re-prompt but make it one tap (#36 already - auto-opens the unlock flow). Decide the security posture before building. Pairs - with 56/57 (same re-prompt UX for uploads + encryption ops). +- [x] **DONE 2026-07-01 — opt-in "keep unlocked" cache.** Decision (maintainer): + **opt-in** tiers. On a successful nsec unlock the decrypted key is cached in a + new `js/nostr/unlockedKeyCache.js`: + - **session tier (default, always):** `sessionStorage` — survives refresh / + navigation, auto-cleared on tab close. Roughly the same exposure as the + in-memory signer, so no more PIN on every reload within a session. + - **persistent tier (opt-in checkbox):** 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 the user clears site data. + `client.restoreUnlockedSigner(pubkey)` re-registers the signer from the cache + WITHOUT a passphrase (verifies the key derives to that pubkey; a mismatch + forgets it). It's called proactively from `ensureActiveSignerForPubkey` (the + auto-login/refresh path) and lazily from `ensureEncryptionCapableSigner` before + any prompt — so after a reload the signer is restored silently and every + signing/encryption flow (storage, hashtags, DMs, reactions, blocks, subs) just + works. Logout / remove-saved-profile forget the cached key + (`forgetUnlockedSigner`). Tests: `tests/unlocked-key-cache.test.mjs` (8). Lint + + build green. +- [ ] **VERIFY on unstable:** unlock once (leave the box unchecked) → refresh → no PIN; + close the tab + reopen → PIN again. Check the box → close/reopen the browser → + still no PIN; Log out or clear site data → PIN again. Confirm the checkbox warning + copy reads clearly. +- [ ] **Follow-up (optional):** a visible "Lock now / forget on this device" control in + the profile menu (today the cache is cleared only on logout / clear-site-data), and + consider surfacing whether the current session is persistently remembered. ### 52. After editing a video, the video grids don't refresh to show the update (BUG) - [ ] Editing a video leaves the grids showing the pre-edit version until a manual