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