[ui] #36: clear, actionable storage-unlock error for a locked nsec session after reload

Root cause (user-confirmed repro): 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 only passphrase-encrypted). Storage unlock then has no signer, and
the fallback (ensureActiveSignerForPubkey) can only rebuild an EXTENSION signer —
so an nsec user dead-ended on the misleading "No active signer found. Please login."
even though they still look logged in. The crypto layer itself is correct: an
empirical repro shows storageService.unlock round-trips master-key encrypt/decrypt
fine with real nsec cipher closures.

Fix: detect the locked persisted-nsec case (getLockedStoredNsecSession via
getStoredSessionActorMetadata, scoped to the account being unlocked) and surface an
actionable error/status ("re-enter your passphrase from the Login menu to unlock
your saved key, 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;
existing storage-service tests still 14/14.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-29 22:19:45 -04:00
1 parent eb4d071203
commit a669040740
3 files changed
+179 -6

No files matched your search

+59 -1
View File
@@ -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)."
);
+91
View File
@@ -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);
});
+29 -5
View File
@@ -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)