From fb2f12b49d1747c43e96ac2436427725454b0bda Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:11:43 -0400 Subject: [PATCH] [auth] #48: fluent switch to a saved nsec account (prompt for PIN + unlock stored key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a saved nsec profile silently failed: switchProfile -> requestLogin -> the nsec provider was called with no passphrase, so it threw "secret-required" and the switch was caught+logged with no user feedback. It never prompted for the PIN. Now handleProfileSwitchRequest detects an nsec target and, before switching, prepares the stored-key unlock: - evaluateStoredNsecSwitch(meta, target) (pure) gates on a persisted nsec key for THIS account (via getStoredSessionActorMetadata); errors clearly otherwise ("log in with its nsec", "different account's key saved"). - showPasswordPrompt (js/ui/promptDialog.js) collects the PIN/passphrase in a styled dialog (Promise, stacks above modals). - switchProfile(pubkey, { unlockStored: true, passphrase }) — authService.switchProfile now forwards unlockStored/passphrase to requestLogin, which routes to the nsec provider's unlockStoredSessionActor to restore the signer and activate the account. - Wrong PIN -> "Incorrect PIN/passphrase" toast (decrypt-failed). So NIP-07 -> nsec switching works fluently. NIP-46 switching is unchanged (follow-up to verify/extend); multiple-nsec accounts still share one stored slot (follow-up). Tests: tests/stored-nsec-switch.test.mjs (6), tests/password-prompt.test.mjs (6). Build + lint clean; auth suites 25/25. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 60 +++++++++++++++- js/app/storedNsecSwitch.js | 24 +++++++ js/services/authService.js | 11 +++ js/ui/promptDialog.js | 111 +++++++++++++++++++++++++++++ tests/password-prompt.test.mjs | 71 ++++++++++++++++++ tests/stored-nsec-switch.test.mjs | 49 +++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 35 +++++---- 7 files changed, 346 insertions(+), 15 deletions(-) create mode 100644 js/app/storedNsecSwitch.js create mode 100644 js/ui/promptDialog.js create mode 100644 tests/password-prompt.test.mjs create mode 100644 tests/stored-nsec-switch.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 22fc2f9a..e156e7d3 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -13,6 +13,8 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js"; +import { showPasswordPrompt } from "../ui/promptDialog.js"; +import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js"; /** * Kick a background watch-history refresh after login, once the feed-driving @@ -1372,12 +1374,68 @@ export function createAuthSessionCoordinator(deps) { } }, + // For an nsec target, ensure its stored key can be unlocked and collect the + // passphrase. Returns { status: "ok", passphrase } | { status: "cancelled" } | + // { status: "error", reason }. + async prepareStoredNsecSwitch(targetPubkey) { + const meta = + typeof nostrClient?.getStoredSessionActorMetadata === "function" + ? nostrClient.getStoredSessionActorMetadata() + : null; + const decision = evaluateStoredNsecSwitch(meta, targetPubkey); + if (decision.action === "error") { + this.showError?.( + decision.reason === "pubkey-mismatch" + ? "A different account's key is saved on this device. Log in with this account's nsec to switch to it." + : "This account's key isn't saved on this device. Log in with its nsec to switch to it.", + ); + return { status: "error", reason: decision.reason }; + } + const passphrase = await showPasswordPrompt( + "Enter your PIN / passphrase to unlock this account's saved key.", + { title: "Switch account", confirmLabel: "Switch" }, + ); + if (passphrase == null || passphrase === "") { + return { status: "cancelled", reason: "cancelled" }; + } + return { status: "ok", passphrase }; + }, + async handleProfileSwitchRequest({ pubkey, providerId } = {}) { if (!pubkey) { throw new Error("Missing pubkey for profile switch request."); } - const result = await this.authService.switchProfile(pubkey, { providerId }); + // Switching to a saved nsec account needs its passphrase — the private key is + // stored encrypted, so switchProfile alone throws "secret-required". Prompt for + // the PIN and unlock the stored key so the switch actually works (matches #36). + let switchOptions = { providerId }; + const isNsecTarget = + String(providerId || "").trim().toLowerCase() === "nsec"; + if (isNsecTarget) { + const prep = await this.prepareStoredNsecSwitch(pubkey); + if (prep.status !== "ok") { + return { switched: false, reason: prep.reason || prep.status }; + } + switchOptions = { + providerId, + unlockStored: true, + passphrase: prep.passphrase, + }; + } + + let result; + try { + result = await this.authService.switchProfile(pubkey, switchOptions); + } catch (error) { + if (isNsecTarget && error?.code === "decrypt-failed") { + this.showError?.( + "Incorrect PIN / passphrase. Try switching to this account again.", + ); + return { switched: false, reason: "bad-passphrase" }; + } + throw error; + } if (result?.switched) { const detail = result.detail || null; diff --git a/js/app/storedNsecSwitch.js b/js/app/storedNsecSwitch.js new file mode 100644 index 00000000..37b6a036 --- /dev/null +++ b/js/app/storedNsecSwitch.js @@ -0,0 +1,24 @@ +// Decide whether switching to a saved nsec account can proceed via the stored key. +// Pure so the branching is unit-testable; the coordinator handles the prompt + I/O. +// +// { action: "prompt" } → stored nsec key matches; ask for the PIN +// { action: "error", reason: "no-stored-key" } → no persisted nsec key on this device +// { action: "error", reason: "pubkey-mismatch" } → the saved key is a different account + +function normalizeHex(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value.trim()) + ? value.trim().toLowerCase() + : ""; +} + +export function evaluateStoredNsecSwitch(meta, targetPubkey) { + if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") { + return { action: "error", reason: "no-stored-key" }; + } + const stored = normalizeHex(meta.pubkey); + const target = normalizeHex(targetPubkey); + if (stored && target && stored !== target) { + return { action: "error", reason: "pubkey-mismatch" }; + } + return { action: "prompt" }; +} diff --git a/js/services/authService.js b/js/services/authService.js index 1715f2ef..16d131b2 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -992,6 +992,17 @@ export default class AuthService { expectPubkey: normalizedTarget, persistActive: true, ...(providerId ? { providerId } : {}), + // Switching to a saved nsec account: unlock its stored key with the passphrase + // the caller collected (the private key can't be restored otherwise). + ...(normalizedOptions.unlockStored + ? { + unlockStored: true, + passphrase: + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : "", + } + : {}), }; const autoApply = requestOptions.autoApply !== false; diff --git a/js/ui/promptDialog.js b/js/ui/promptDialog.js new file mode 100644 index 00000000..33d6c171 --- /dev/null +++ b/js/ui/promptDialog.js @@ -0,0 +1,111 @@ +// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of +// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g. +// when switching to a saved nsec account. Resolves the entered string, or null if the +// 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). + +export function showPasswordPrompt(message, options = {}) { + const { + title = "", + confirmLabel = "Unlock", + cancelLabel = "Cancel", + placeholder = "PIN / passphrase", + } = options || {}; + + if (typeof document === "undefined" || !document.body) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "bv-modal modal-always-on-top items-start justify-center md:items-center"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const backdrop = document.createElement("div"); + backdrop.className = "bv-modal-backdrop"; + overlay.appendChild(backdrop); + + const sheet = document.createElement("div"); + sheet.className = "modal-sheet w-full max-w-md flex flex-col"; + sheet.tabIndex = -1; + overlay.appendChild(sheet); + + if (title) { + const header = document.createElement("div"); + header.className = "modal-header"; + const heading = document.createElement("h2"); + heading.className = "text-lg font-bold text-text"; + heading.textContent = title; + header.appendChild(heading); + sheet.appendChild(header); + } + + const body = document.createElement("div"); + body.className = "p-5 space-y-3"; + if (message) { + const text = document.createElement("p"); + text.className = "text-sm text-muted-strong whitespace-pre-line"; + text.textContent = String(message); + body.appendChild(text); + } + const input = document.createElement("input"); + input.type = "password"; + input.className = "input w-full"; + input.placeholder = placeholder; + input.autocomplete = "off"; + input.setAttribute("aria-label", placeholder); + body.appendChild(input); + sheet.appendChild(body); + + const footer = document.createElement("div"); + footer.className = + "flex items-center justify-end gap-3 border-t border-border/60 p-4"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn-ghost focus-ring"; + cancelBtn.textContent = cancelLabel; + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "btn focus-ring"; + confirmBtn.textContent = confirmLabel; + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + sheet.appendChild(footer); + + let settled = false; + const cleanup = (result) => { + if (settled) { + return; + } + settled = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + resolve(result); + }; + const submit = () => cleanup(input.value); + function onKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + cleanup(null); + } + } + + cancelBtn.addEventListener("click", () => cleanup(null)); + backdrop.addEventListener("click", () => cleanup(null)); + confirmBtn.addEventListener("click", submit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }); + document.addEventListener("keydown", onKeydown, true); + + document.body.appendChild(overlay); + input.focus(); + }); +} + +export default showPasswordPrompt; diff --git a/tests/password-prompt.test.mjs b/tests/password-prompt.test.mjs new file mode 100644 index 00000000..86ee8fa2 --- /dev/null +++ b/tests/password-prompt.test.mjs @@ -0,0 +1,71 @@ +// showPasswordPrompt() collects a passphrase/PIN via a styled dialog (used to unlock a +// stored nsec key when switching accounts). Resolves the entered string, or null when +// the user cancels/escapes. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { showPasswordPrompt } from "../js/ui/promptDialog.js"; + +let dom; +beforeEach(() => { + dom = new JSDOM(""); + globalThis.document = dom.window.document; + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; +}); +afterEach(() => { + delete globalThis.document; + delete globalThis.window; + delete globalThis.HTMLElement; + delete globalThis.KeyboardEvent; +}); + +const overlay = () => dom.window.document.querySelector(".bv-modal"); +const input = () => overlay().querySelector('input[type="password"]'); + +test("resolves the entered value on Confirm and removes the dialog", async () => { + const p = showPasswordPrompt("Enter your PIN", { confirmLabel: "Switch" }); + input().value = "hunter2"; + overlay().querySelector(".btn").click(); // confirm (cancel is .btn-ghost) + assert.equal(await p, "hunter2"); + assert.equal(overlay(), null, "dialog removed after resolving"); +}); + +test("resolves the value when Enter is pressed in the input", async () => { + const p = showPasswordPrompt("PIN"); + const el = input(); + el.value = "s3cret"; + el.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + assert.equal(await p, "s3cret"); +}); + +test("resolves null when cancelled", async () => { + const p = showPasswordPrompt("PIN"); + overlay().querySelector(".btn-ghost").click(); + assert.equal(await p, null); +}); + +test("resolves null on Escape", async () => { + const p = showPasswordPrompt("PIN"); + dom.window.document.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + assert.equal(await p, null); +}); + +test("focuses the input so the user can type immediately", async () => { + const p = showPasswordPrompt("PIN"); + assert.equal(dom.window.document.activeElement, input()); + overlay().querySelector(".btn-ghost").click(); + await p; +}); + +test("without a DOM it resolves null rather than throwing", async () => { + delete globalThis.document; + delete globalThis.window; + assert.equal(await showPasswordPrompt("PIN"), null); +}); diff --git a/tests/stored-nsec-switch.test.mjs b/tests/stored-nsec-switch.test.mjs new file mode 100644 index 00000000..e3aa89d6 --- /dev/null +++ b/tests/stored-nsec-switch.test.mjs @@ -0,0 +1,49 @@ +// #48: switching to a saved nsec account must prompt for the PIN and unlock its stored +// key (switchProfile alone threw "secret-required"). evaluateStoredNsecSwitch is the +// pure gate: proceed to prompt when a matching stored nsec key exists; error otherwise. + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { evaluateStoredNsecSwitch } from "../js/app/storedNsecSwitch.js"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const nsecMeta = (pubkey = A) => ({ hasEncryptedKey: true, pubkey, source: "nsec" }); + +test("prompts when a stored nsec key matches the target account", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), A), { action: "prompt" }); +}); + +test("prompts when the stored key has no pubkey to compare (can't rule it out)", () => { + assert.deepEqual( + evaluateStoredNsecSwitch({ hasEncryptedKey: true, source: "nsec" }, A), + { action: "prompt" }, + ); +}); + +test("errors when there is no stored session at all", () => { + assert.deepEqual(evaluateStoredNsecSwitch(null, A), { + action: "error", + reason: "no-stored-key", + }); +}); + +test("errors when the stored key isn't an nsec (e.g. left-over non-nsec metadata)", () => { + assert.deepEqual( + evaluateStoredNsecSwitch({ hasEncryptedKey: true, pubkey: A, source: "extension" }, A), + { action: "error", reason: "no-stored-key" }, + ); +}); + +test("errors when the stored key belongs to a DIFFERENT account", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), B), { + action: "error", + reason: "pubkey-mismatch", + }); +}); + +test("case-insensitive pubkey match", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A.toUpperCase()), A), { + action: "prompt", + }); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 041cc7bb..8b70a0cd 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -594,20 +594,27 @@ toward freshness and looked identical. Gave each a structural identity: reliable zap data (couples with the zap system already shipped in #3). ### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) -- [ ] **CONFIRMED NOT DONE (verified 2026-06-30).** Switching to a saved **nsec** profile - fails: `switchProfile(pubkey, { providerId: "nsec" })` → `requestLogin` → the nsec - provider's `login()` is called with **no secret/passphrase**, so it throws - `"secret-required"` and the switch silently fails (caught + logged in - `profileModalController`). It never prompts for the PIN or routes to the stored-key - unlock flow. Same root gap as #36 (nsec signer needs the passphrase to be restored), - but in the SWITCH path. Fix: when switching to a saved nsec profile, detect the - stored encrypted key (`getStoredSessionActorMetadata`) and prompt for the pin/ - passphrase (route through `unlockStoredSessionActor`, e.g. open the login modal's - unlock flow), then activate. Apply the same for **NIP-46** (reconnect / re-authorize - the remote signer on switch if needed). Goal: switch fluently between NIP-07 ⇄ nsec - ⇄ NIP-46 accounts regardless of the current signing method. NOTE: multiple saved - nsec accounts share ONE stored session entry today — confirm per-account key storage - is needed for switching among several nsec profiles. +- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail + silently ("secret-required") — `switchProfile` called the nsec provider with no + passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec + target, checks the stored key (`getStoredSessionActorMetadata` + + `evaluateStoredNsecSwitch` — must be a persisted nsec for THIS account), prompts for + the PIN/passphrase via a new `showPasswordPrompt` dialog + (`js/ui/promptDialog.js`), and calls `switchProfile(pubkey, { unlockStored: true, + passphrase })`. `authService.switchProfile` now forwards `unlockStored`/`passphrase` + → `requestLogin` → the nsec provider's `unlockStoredSessionActor` restores the signer + and activates the account. Wrong PIN → "Incorrect PIN/passphrase" toast; no stored + key / different account → a clear "log in with its nsec" toast. So you can switch + NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) + + `tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25. +- [ ] **VERIFY + extend to NIP-46 (follow-up).** My change only intercepts nsec targets; + NIP-46 switching is unchanged (`switchProfile({ providerId: "nip46" })`). Verify a + NIP-07 → NIP-46 (and nsec → NIP-46) switch actually reconnects/re-authorizes the + remote signer; if it fails, apply the same "prep before switch" pattern (reconnect + the bunker / re-open the NIP-46 connect flow). +- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot + (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device — + switching among several saved nsec accounts needs per-account encrypted-key storage. ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no