diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 679443d7..0b49185b 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -1,4 +1,4 @@ -import { devLogger } from "../../utils/logger.js"; +import { devLogger, userLogger } from "../../utils/logger.js"; import { PROVIDERS } from "../../services/storageService.js"; import { prepareS3Connection, @@ -170,6 +170,11 @@ export class ProfileStorageController { } else if (this.storageUnlockFailure?.message) { this.storageStatusText.textContent = `Locked (${this.storageUnlockFailure.message})`; this.storageStatusText.className = "text-xs text-status-danger"; + } else if (this.getLockedStoredNsecSession(pubkey)) { + // Saved nsec key not yet re-unlocked after reload — point the user at the fix. + this.storageStatusText.textContent = + "Locked — re-unlock your saved key (Login → passphrase) to manage storage."; + this.storageStatusText.className = "text-xs text-status-warning"; } else { this.storageStatusText.textContent = "Locked"; this.storageStatusText.className = "text-xs text-status-warning"; @@ -451,6 +456,8 @@ export class ProfileStorageController { return "Storage unlock requires extension encryption permission. Approve the prompt, then retry unlock."; case "storage-unlock-no-decryptor": return "Your signer cannot decrypt storage keys. Use a signer with NIP-44 or NIP-04 decrypt support."; + case "storage-unlock-locked-nsec-session": + return "Your saved key is locked after reloading the page. Open the Login menu and re-enter your passphrase to unlock your saved key, then unlock storage."; case "storage-unlock-decrypt-failed": return "Unable to decrypt your saved storage key. Retry unlock and confirm the active account matches."; default: @@ -481,6 +488,47 @@ export class ProfileStorageController { this.storageUnlockFailure = null; } + // After a page reload, a persisted ("remember this key") nsec session restores the + // logged-in pubkey + UI but NOT the in-memory signer — the private key is held only + // passphrase-encrypted. Storage unlock then has no signer (or a decrypt-less stub), + // and the generic "No active signer / cannot decrypt" errors are misleading because + // the user still looks logged in. Detect this exact case (a saved nsec key for the + // account we're unlocking) so we can tell them to re-unlock their saved key instead. + getLockedStoredNsecSession(pubkey) { + const client = this.mainController.services?.nostrClient; + if (!client || typeof client.getStoredSessionActorMetadata !== "function") { + return null; + } + let meta = null; + try { + meta = client.getStoredSessionActorMetadata(); + } catch (error) { + return null; + } + if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") { + return null; + } + const metaPubkey = this.mainController.normalizeHexPubkey(meta.pubkey); + // Only treat it as "this account is locked" when the saved key is for the pubkey + // we're actually trying to unlock (don't hijack a different-account situation). + if (pubkey && metaPubkey && metaPubkey !== pubkey) { + return null; + } + return meta; + } + + reportLockedNsecSession() { + userLogger.info( + "[storage-unlock] saved nsec key is locked (no in-memory signer after reload); prompting re-unlock.", + ); + const error = new Error( + "Your saved key is locked after reloading the page. Re-enter your passphrase from the Login menu to unlock it, then unlock storage.", + ); + error.code = "storage-unlock-locked-nsec-session"; + this.setStorageUnlockFailureState(error); + this.mainController.showError(this.getStorageUnlockFailureMessage(error)); + } + async requestStorageUnlockPermissions() { const client = this.mainController.services?.nostrClient; if (!client || typeof client.ensureExtensionPermissions !== "function") { @@ -521,6 +569,10 @@ export class ProfileStorageController { } if (!signer) { + if (this.getLockedStoredNsecSession(pubkey)) { + this.reportLockedNsecSession(); + return; + } this.mainController.showError("No active signer found. Please login."); return; } @@ -592,6 +644,12 @@ export class ProfileStorageController { typeof signer?.decrypt === "function"; if (!hasNip44Decrypt && !hasNip04Decrypt) { + // A restored-but-locked persisted nsec session can leave a decrypt-less stub + // signer; route it to the re-unlock guidance rather than the generic message. + if (this.getLockedStoredNsecSession(pubkey)) { + this.reportLockedNsecSession(); + return; + } const missingDecryptError = new Error( "This signer cannot decrypt storage keys (NIP-44/NIP-04 missing)." ); diff --git a/tests/storage-unlock-locked-nsec.test.mjs b/tests/storage-unlock-locked-nsec.test.mjs new file mode 100644 index 00000000..c085df43 --- /dev/null +++ b/tests/storage-unlock-locked-nsec.test.mjs @@ -0,0 +1,91 @@ +// #36: storage unlock fails for an nsec user AFTER A PAGE RELOAD. +// +// A persisted ("remember this key") nsec session restores the logged-in pubkey + UI +// on reload, but NOT the in-memory signer — the private key is held only passphrase- +// encrypted. Storage unlock then has no signer (or a decrypt-less stub), and the old +// code dead-ended with the misleading "No active signer found. Please login." even +// though the user still looks logged in. +// +// The fix detects this exact case (a saved nsec key for the account being unlocked) +// and surfaces an actionable error pointing the user at re-unlocking their saved key, +// instead of the generic signer/decryptor errors. These tests exercise the detection +// + reporting directly (no DOM, no module-level signer dependency). + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { ProfileStorageController } from "../js/ui/profileModal/ProfileStorageController.js"; + +const PUBKEY = "a".repeat(64); +const OTHER_PUBKEY = "b".repeat(64); + +function makeController({ meta } = {}) { + const shownErrors = []; + const mainController = { + normalizeHexPubkey: (value) => + typeof value === "string" ? value.trim().toLowerCase() : "", + getActivePubkey: () => PUBKEY, + showError: (msg) => shownErrors.push(msg), + services: { + nostrClient: { + getStoredSessionActorMetadata: () => meta, + }, + }, + }; + const controller = new ProfileStorageController(mainController); + return { controller, shownErrors }; +} + +const persistedNsecMeta = { + hasEncryptedKey: true, + pubkey: PUBKEY, + source: "nsec", + createdAt: 1234, +}; + +test("detects a locked persisted nsec session for the active account", () => { + const { controller } = makeController({ meta: persistedNsecMeta }); + const detected = controller.getLockedStoredNsecSession(PUBKEY); + assert.ok(detected, "should detect the locked saved key"); + assert.equal(detected.source, "nsec"); +}); + +test("does NOT treat a missing saved key as a locked session", () => { + const { controller } = makeController({ meta: null }); + assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null); +}); + +test("does NOT treat a non-nsec session as a locked nsec session", () => { + const { controller } = makeController({ + meta: { hasEncryptedKey: true, pubkey: PUBKEY, source: "extension" }, + }); + assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null); +}); + +test("does NOT claim 'this account is locked' when the saved key is a DIFFERENT account", () => { + const { controller } = makeController({ + meta: { hasEncryptedKey: true, pubkey: OTHER_PUBKEY, source: "nsec" }, + }); + assert.equal(controller.getLockedStoredNsecSession(PUBKEY), null); +}); + +test("reporting the locked session shows the actionable re-unlock message, not a generic signer error", () => { + const { controller, shownErrors } = makeController({ meta: persistedNsecMeta }); + controller.reportLockedNsecSession(); + + assert.equal(controller.storageUnlockFailure?.code, "storage-unlock-locked-nsec-session"); + assert.equal(shownErrors.length, 1); + const msg = shownErrors[0]; + // Must direct the user to re-unlock the SAVED KEY (passphrase), not "please login". + assert.match(msg, /saved key/i); + assert.match(msg, /passphrase/i); + assert.doesNotMatch(msg, /no active signer/i); +}); + +test("the new error code maps to a saved-key/passphrase message", () => { + const { controller } = makeController({ meta: persistedNsecMeta }); + const msg = controller.getStorageUnlockFailureMessage({ + code: "storage-unlock-locked-nsec-session", + }); + assert.match(msg, /saved key/i); + assert.match(msg, /passphrase/i); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 0dfa5ba6..64391754 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -794,11 +794,35 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro action, batch operations, and faster list mutation. Couples with #22/#23. ### 36. Storage unlock doesn't work with nsec login (BUG) -- [ ] With an **nsec** login, unlocking encrypted storage credentials fails. The - storage-unlock flow likely assumes a different signer/key path than the nsec - session provides. Audit the unlock path (`StorageService` master-key decrypt + - `js/services/authService.js`) for the nsec case so nsec users can use hosted - storage. (Relates to the credential-encryption work in #15.) +- [x] **ROOT-CAUSED + FIXED 2026-06-29.** Audited the whole nsec unlock path and built + an empirical repro: the crypto layer is **correct** — with real nsec cipher + closures (`createPrivateKeyCipherClosures`), `storageService.unlock` round-trips + master-key encrypt→lock→decrypt perfectly (NIP-44). Both nsec login paths + (`registerPrivateKeySigner`, `unlockStoredSessionActor`) register a signer exposing + `nip04Decrypt`/`nip44Decrypt`. **The real failure is session-shape after a page + reload** (user-confirmed repro): a persisted "remember this key" nsec session + restores the logged-in **pubkey + UI but NOT the in-memory signer** (the private key + is only passphrase-encrypted). `ProfileStorageController`'s fallback + (`ensureActiveSignerForPubkey`) can only rebuild an *extension* signer, so an nsec + user hit a dead-end: storage unlock found no signer and showed the misleading "No + active signer found. Please login." even though they look logged in. + **Fix:** detect this exact case (`getLockedStoredNsecSession` — a saved nsec key for + the account being unlocked via `getStoredSessionActorMetadata`) and surface an + **actionable** error/status ("Your saved key is locked after reload — re-enter your + passphrase from the Login menu to unlock it, then unlock storage") in both the + Unlock-button flow and the passive Storage-pane status text, instead of the generic + no-signer / no-decryptor messages. New error code + `storage-unlock-locked-nsec-session`. Tests: + `tests/storage-unlock-locked-nsec.test.mjs` (6, behavioral). Build + lint clean. +- [ ] **Follow-up (UX nicety):** auto-open the login modal's existing unlock-saved-key + (passphrase) flow directly from the storage pane so the user re-unlocks in one + click instead of navigating to Login — needs a side-effect-free + `requestUnlockStoredSession` hook (the current `requestAddProfileLogin` path runs an + add-account callback). Deferred to keep this fix small. +- [ ] **VERIFY on unstable:** nsec-login with "remember key" + passphrase, reload the + page, open Profile → Storage → the status should read the actionable re-unlock hint + (not "No active signer"); after re-entering the passphrase via Login, storage + unlocks normally. (`[storage-unlock]` console line confirms the locked-key branch.) ### 37. Channel playlists — creator-curated custom note lists - [ ] Let creators build **playlists** (custom curated lists of their videos / notes)