[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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 19:51:41 -04:00
1 parent 2dc7397535
commit 1b17bb8fcc
7 files changed
+556 -11

No files matched your search

+30 -1
View File
@@ -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") {
+27
View File
@@ -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 };
+110 -1
View File
@@ -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);
}
+184
View File
@@ -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;
+44 -1
View File
@@ -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();
+135
View File
@@ -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();
});
});
+26 -8
View File
@@ -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