mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[auth] #48: fluent switch to a saved nsec account (prompt for PIN + unlock stored key)
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<string|null>, 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 <[email protected]>
This commit is contained in:
1 parent
577e5d27c5
commit
fb2f12b49d
7 files changed
+346
-15
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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("<!doctype html><html><body></body></html>");
|
||||
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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user