mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
2dc7397535
commit
1b17bb8fcc
7 files changed
+556
-11
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user