From 1b79ab9d097b1fc5a3f3dab9e727b84f3b0d6dae Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:11:19 -0400 Subject: [PATCH] [auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A persisted nsec session restores the pubkey + UI on reload but NOT the in-memory signer (the private key is only passphrase-encrypted — TODO #36). So after a refresh, adding/editing a hashtag on an nsec account dead-ended with "Connect a Nostr signer that supports encryption before managing hashtag preferences" — even though the user looks logged in. Same class blocks subscriptions/blocks/DMs/reactions. New reusable gate app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage }): checks the active signer's capabilities; if it can't encrypt/sign AND a MATCHING locked nsec key exists for the account, prompts once for the passphrase and re-unlocks the signer app-wide via unlockStoredSessionActor. One unlock re-enables every signing flow until the next reload. Never unlocks a different account's key (pubkey-mismatch guard); "no-stored-key" leaves non-nsec cases (e.g. an unresponsive NIP-07 extension) to the caller's own error. Branching lives in js/nostr/ensureSignerDecision.js (pure, unit-testable without the app shell). Wired hashtag add/edit through it (ProfileHashtagController.persistHashtagPreferences): a cancelled prompt aborts silently, a bad passphrase shows its own toast, and publish never runs until the signer is confirmed. Exposed to the modal as services.ensureEncryptionCapableSigner. This is the shared machinery for the whole reloaded-nsec cluster (#51/#54/#56/#57); subscriptions/blocks/DM-send/reactions are mechanical fast-follows against the same gate (tracked in TODO #57). Tests: tests/ensure-signer-decision.test.mjs (9 — capability + recoverable/fail branching, never-wrong-key), + 2 wiring tests (gate blocks publish until ok) in tests/profile-modal-controller.test.mjs. Lint + build green. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 100 +++++++++++++ js/nostr/ensureSignerDecision.js | 56 ++++++++ js/ui/applicationBootstrap.js | 5 + .../profileModal/ProfileHashtagController.js | 39 +++++ tests/ensure-signer-decision.test.mjs | 134 ++++++++++++++++++ tests/profile-modal-controller.test.mjs | 87 ++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 21 +++ 7 files changed, 442 insertions(+) create mode 100644 js/nostr/ensureSignerDecision.js create mode 100644 tests/ensure-signer-decision.test.mjs diff --git a/js/app.js b/js/app.js index 3220c254..1dc48c90 100644 --- a/js/app.js +++ b/js/app.js @@ -97,6 +97,12 @@ import { getActiveSigner, onActiveSignerChanged, } from "./nostrClientRegistry.js"; +import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js"; +import { + decideSignerEnsure, + isSignerCapable, +} from "./nostr/ensureSignerDecision.js"; +import { showPasswordPrompt } from "./ui/promptDialog.js"; import { queueSignEvent } from "./nostr/signRequestQueue.js"; import { DEFAULT_NIP07_CORE_METHODS, @@ -2074,6 +2080,100 @@ class Application { return fallback; } + /** + * Ensure the ACTIVE signer can encrypt/sign. If it can't because a persisted + * nsec session restored the pubkey + UI but not the in-memory signer after a + * page reload (the private key is only passphrase-encrypted — see TODO #36), + * prompt once for the passphrase and re-unlock the signer app-wide via + * `unlockStoredSessionActor`. One unlock re-enables every signing/encryption + * flow (hashtags, subscriptions, blocks, DMs, reactions, storage) until the + * next reload — instead of each guard dead-ending with a blanket + * "connect a signer that supports encryption" error (TODO #51/#54/#56/#57). + * + * @param {object} [opts] + * @param {string} [opts.pubkey] - Account to unlock (defaults to the active one). + * @param {"encrypt"|"sign"} [opts.need] - Capability required. "encrypt" needs + * nip44/nip04; "sign" needs signEvent only. + * @param {string} [opts.promptMessage] - Override the passphrase-prompt copy. + * @returns {Promise<{ok: boolean, reason?: string, unlocked?: boolean, error?: Error}>} + */ + async ensureEncryptionCapableSigner({ + pubkey, + need = "encrypt", + promptMessage, + } = {}) { + const normalized = + this.normalizeHexPubkey(pubkey) || + this.normalizeHexPubkey(this.pubkey) || + ""; + + let meta = null; + try { + meta = + normalized && + typeof nostrClient?.getStoredSessionActorMetadata === "function" + ? nostrClient.getStoredSessionActorMetadata(normalized) + : null; + } catch (error) { + devLogger.warn( + "[Application] Failed to read stored session metadata while ensuring signer:", + error, + ); + } + + const decision = decideSignerEnsure({ + capabilities: resolveSignerCapabilities(getActiveSigner()), + need, + normalizedPubkey: normalized, + storedKeyMeta: meta, + normalizePubkey: (value) => this.normalizeHexPubkey(value) || "", + }); + + if (decision.action === "ok") { + return { ok: true }; + } + if (decision.action === "fail") { + return { ok: false, reason: decision.reason }; + } + + const message = + typeof promptMessage === "string" && promptMessage.trim() + ? promptMessage.trim() + : "Re-enter your PIN / passphrase to unlock your saved key for this action."; + const passphrase = await showPasswordPrompt(message, { + title: "Unlock your key", + confirmLabel: "Unlock", + }); + if (passphrase == null || passphrase === "") { + return { ok: false, reason: "cancelled" }; + } + + try { + await nostrClient.unlockStoredSessionActor(passphrase, { + pubkey: normalized, + }); + } catch (error) { + if (error?.code === "decrypt-failed") { + this.showError?.( + "Incorrect PIN / passphrase. Try that action again to re-enter it.", + ); + return { ok: false, reason: "bad-passphrase", error }; + } + devLogger.warn( + "[Application] Failed to unlock stored nsec session for a signing action:", + error, + ); + return { ok: false, reason: "unlock-failed", error }; + } + + // The signer is now registered app-wide; confirm the capability actually + // came back before telling the caller to proceed. + if (!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)) { + return { ok: false, reason: "still-incapable" }; + } + return { ok: true, unlocked: true }; + } + resetPermissionPromptState() { this.permissionPromptShownForSession = false; this.permissionPromptVisible = false; diff --git a/js/nostr/ensureSignerDecision.js b/js/nostr/ensureSignerDecision.js new file mode 100644 index 00000000..ba741b23 --- /dev/null +++ b/js/nostr/ensureSignerDecision.js @@ -0,0 +1,56 @@ +// Pure decision for `app.ensureEncryptionCapableSigner`. Given the active signer's +// current capabilities and whether a locked (passphrase-encrypted) nsec key exists +// for the active account, decide whether to proceed, fail, or prompt the user to +// re-unlock their key. Kept side-effect-free so the branching is unit-testable +// without the browser app shell (TODO #51/#54/#56/#57). +// +// Returns one of: +// { action: "ok" } — signer already capable +// { action: "prompt" } — recoverable: prompt + unlock nsec +// { action: "fail", reason: } — not recoverable here + +const defaultNormalize = (value) => + typeof value === "string" ? value.trim().toLowerCase() : ""; + +export function isSignerCapable(capabilities, need = "encrypt") { + const caps = + capabilities && typeof capabilities === "object" ? capabilities : {}; + if (!caps.sign) { + return false; + } + return need === "sign" ? true : Boolean(caps.nip44) || Boolean(caps.nip04); +} + +export function decideSignerEnsure({ + capabilities, + need = "encrypt", + normalizedPubkey, + storedKeyMeta, + normalizePubkey = defaultNormalize, +} = {}) { + if (isSignerCapable(capabilities, need)) { + return { action: "ok" }; + } + + if (!normalizedPubkey) { + return { action: "fail", reason: "no-signer" }; + } + + // Only an nsec-after-reload situation is recoverable here: a saved, locked key + // for this exact account we can decrypt with the passphrase. Anything else + // (e.g. an unresponsive NIP-07 extension) is the caller's own error to surface. + if (!storedKeyMeta || storedKeyMeta.hasEncryptedKey !== true) { + return { action: "fail", reason: "no-stored-key" }; + } + + if ( + storedKeyMeta.pubkey && + normalizePubkey(storedKeyMeta.pubkey) !== normalizedPubkey + ) { + return { action: "fail", reason: "pubkey-mismatch" }; + } + + return { action: "prompt" }; +} + +export default decideSignerEnsure; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 55a0099e..206b30ab 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -572,6 +572,11 @@ export default class ApplicationBootstrap { }, describeLoginError: (error, fallbackMessage) => app.describeLoginError(error, fallbackMessage), + // Ensure the active signer can encrypt/sign before a list mutation; if a + // reloaded nsec session lost its in-memory key, this prompts once to + // re-unlock it instead of dead-ending with a blanket error (TODO #57). + ensureEncryptionCapableSigner: (options = {}) => + app.ensureEncryptionCapableSigner(options), hashtagPreferences: app.hashtagPreferences, getHashtagPreferences: () => app.getHashtagPreferences(), describeHashtagPreferencesError: (error, fallbackMessage) => diff --git a/js/ui/profileModal/ProfileHashtagController.js b/js/ui/profileModal/ProfileHashtagController.js index be8e8b7b..5d91a5bb 100644 --- a/js/ui/profileModal/ProfileHashtagController.js +++ b/js/ui/profileModal/ProfileHashtagController.js @@ -336,6 +336,45 @@ export class ProfileHashtagController { const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; + // Managing hashtags needs an encryption-capable signer. If a reloaded nsec + // session lost its in-memory key, re-unlock it (one passphrase prompt) instead + // of dead-ending with "connect a signer that supports encryption" (TODO #57). + const ensureSigner = + this.mainController.services?.ensureEncryptionCapableSigner; + if (typeof ensureSigner === "function") { + let ensured = { ok: true }; + try { + ensured = await ensureSigner({ + pubkey: normalizedPubkey, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock your key and manage hashtag preferences.", + }); + } catch (error) { + ensured = { ok: false, reason: "ensure-failed", error }; + } + if (!ensured?.ok) { + // "bad-passphrase" already showed its own toast; "cancelled" is a silent + // user choice. Only surface the generic hint for other reasons. + if (ensured?.reason !== "cancelled" && ensured?.reason !== "bad-passphrase") { + const message = this.describeHashtagPreferencesError( + Object.assign(new Error("Signer unavailable."), { + code: "hashtag-preferences-missing-signer", + }), + ); + if (message) { + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + } + } else { + this.setHashtagStatus("", "muted"); + } + const error = new Error("Signer unavailable for hashtag preferences."); + error.code = "hashtag-preferences-missing-signer"; + throw error; + } + } + const pendingMessage = typeof progressMessage === "string" && progressMessage.trim() ? progressMessage.trim() diff --git a/tests/ensure-signer-decision.test.mjs b/tests/ensure-signer-decision.test.mjs new file mode 100644 index 00000000..0fb26b33 --- /dev/null +++ b/tests/ensure-signer-decision.test.mjs @@ -0,0 +1,134 @@ +// The account-switch / reload signer gate (app.ensureEncryptionCapableSigner) +// decides whether a signing/encryption action can proceed, must fail, or should +// prompt the user to re-unlock a persisted nsec key. decideSignerEnsure is the +// pure branching behind it (TODO #51/#54/#56/#57). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-signer-ensure-decision +// given: "current signer capabilities + whether a locked nsec key exists" +// when: "decideSignerEnsure runs" +// then: "ok when capable, prompt when recoverable, fail with a precise reason otherwise" +// observable_outcomes: +// - "capable encrypt signer -> {action:ok}" +// - "no signer + no pubkey -> fail:no-signer" +// - "incapable + no stored key -> fail:no-stored-key" +// - "incapable + stored key for a DIFFERENT pubkey -> fail:pubkey-mismatch" +// - "incapable + matching locked key -> prompt" +// - "need:sign only requires signEvent (nip44/nip04 not required)" +// determinism_controls: +// - "pure function; explicit inputs; injected normalizePubkey" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { + decideSignerEnsure, + isSignerCapable, +} from "../js/nostr/ensureSignerDecision.js"; + +const PUB = "a".repeat(64); +const OTHER = "b".repeat(64); +const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : ""); + +test("capable encrypt-signer proceeds", () => { + const d = decideSignerEnsure({ + capabilities: { sign: true, nip44: true, nip04: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB }, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "ok" }); +}); + +test("nip04-only signer is still encrypt-capable", () => { + assert.equal(isSignerCapable({ sign: true, nip04: true }, "encrypt"), true); +}); + +test("a sign-only signer is NOT encrypt-capable but IS sign-capable", () => { + const caps = { sign: true, nip44: false, nip04: false }; + assert.equal(isSignerCapable(caps, "encrypt"), false); + assert.equal(isSignerCapable(caps, "sign"), true); + // need:sign proceeds even without any encryption method + assert.deepEqual( + decideSignerEnsure({ + capabilities: caps, + need: "sign", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB }, + normalizePubkey: norm, + }), + { action: "ok" }, + ); +}); + +test("no signer and no pubkey -> fail:no-signer", () => { + const d = decideSignerEnsure({ + capabilities: { sign: false }, + need: "encrypt", + normalizedPubkey: "", + storedKeyMeta: null, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "fail", reason: "no-signer" }); +}); + +test("incapable but no stored nsec key -> fail:no-stored-key (caller surfaces its own error)", () => { + const d = decideSignerEnsure({ + capabilities: { sign: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: null, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "fail", reason: "no-stored-key" }); +}); + +test("stored key belongs to a DIFFERENT account -> fail:pubkey-mismatch (never unlock the wrong key)", () => { + const d = decideSignerEnsure({ + capabilities: { sign: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: true, pubkey: OTHER }, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "fail", reason: "pubkey-mismatch" }); +}); + +test("incapable + matching locked nsec key -> prompt to re-unlock", () => { + const d = decideSignerEnsure({ + capabilities: { sign: true, nip44: false, nip04: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB }, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "prompt" }); +}); + +test("stored key with no pubkey field still prompts (metadata may omit it)", () => { + const d = decideSignerEnsure({ + capabilities: { sign: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: true }, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "prompt" }); +}); + +test("hasEncryptedKey must be strictly true (a falsey/omitted flag never prompts)", () => { + const d = decideSignerEnsure({ + capabilities: { sign: false }, + need: "encrypt", + normalizedPubkey: PUB, + storedKeyMeta: { hasEncryptedKey: "yes", pubkey: PUB }, + normalizePubkey: norm, + }); + assert.deepEqual(d, { action: "fail", reason: "no-stored-key" }); +}); diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index bef794f6..19a00871 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -1475,3 +1475,90 @@ test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the mo 'open modal: re-runs the active pane so it reloads for the new identity', ); }); + +// Hashtag mutation routes through the signer gate (TODO #57): a reloaded nsec +// session that lost its in-memory key must trigger a re-unlock prompt, not a +// blanket "connect a signer that supports encryption" error — and publish must +// NOT run until the signer is confirmed. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-hashtag-signer-gate +// given: "a profile modal with a mocked ensureEncryptionCapableSigner + publish" +// when: "persistHashtagPreferences runs" +// then: "publish is gated on the signer being ensured first" +// observable_outcomes: +// - "gate returns not-ok -> publish never called, throws missing-signer" +// - "gate returns ok -> publish runs once" +// determinism_controls: +// - "mocked service + gate; JSDOM controller; no network" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +test('hashtag persist: a not-ok signer gate blocks publish (no blanket dead-end)', async (t) => { + const publishCalls = []; + let ensureCalls = 0; + const controller = createController({ + services: { + ensureEncryptionCapableSigner: async () => { + ensureCalls += 1; + return { ok: false, reason: 'cancelled' }; + }, + hashtagPreferences: { + publish: async (payload) => { + publishCalls.push(payload); + return { ok: true }; + }, + }, + }, + }); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + controller.setActivePubkey('a'.repeat(64)); + + await assert.rejects( + () => + controller.hashtagController.persistHashtagPreferences({ + pubkey: 'a'.repeat(64), + }), + (err) => err?.code === 'hashtag-preferences-missing-signer', + ); + assert.equal(ensureCalls, 1, 'the signer gate ran before publishing'); + assert.equal(publishCalls.length, 0, 'publish was NOT attempted after the gate failed'); +}); + +test('hashtag persist: an ok signer gate lets publish proceed', async (t) => { + const publishCalls = []; + const controller = createController({ + services: { + ensureEncryptionCapableSigner: async () => ({ ok: true, unlocked: true }), + hashtagPreferences: { + publish: async (payload) => { + publishCalls.push(payload); + return { ok: true }; + }, + }, + }, + }); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + controller.setActivePubkey('a'.repeat(64)); + + await controller.hashtagController.persistHashtagPreferences({ + pubkey: 'a'.repeat(64), + }); + assert.equal(publishCalls.length, 1, 'publish ran once after the gate passed'); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 183e6bd9..7fa24897 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1640,3 +1640,24 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. dead-end error. Cross-ref **#48** (fluent switching) and the account-switch refresh work (commit `87af650b`). Audit every "signer that supports encryption" guard and route it to the unlock prompt when a saved nsec key exists for the active pubkey. +- [x] **Shared gate built + hashtags wired — DONE 2026-07-01.** New reusable + `app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage })` + (`js/app.js`, pure branching in `js/nostr/ensureSignerDecision.js`): checks the + active signer's capabilities; if it can't encrypt/sign AND a **matching** locked + nsec key exists for the account, prompts once for the passphrase and re-unlocks + the signer app-wide via `unlockStoredSessionActor` (one unlock re-enables every + flow until the next reload). Never unlocks a different account's key + (pubkey-mismatch guard). Exposed to the profile modal as + `services.ensureEncryptionCapableSigner`. **Hashtag add/edit** now routes through + it (`ProfileHashtagController.persistHashtagPreferences`): a cancelled prompt + silently aborts, a bad passphrase shows its own toast, and publish never runs + until the signer is confirmed. Tests: `tests/ensure-signer-decision.test.mjs` (9), + + 2 wiring tests in `tests/profile-modal-controller.test.mjs`. Lint + build green. +- [ ] **Fast-follows (same gate, remaining call sites):** wire `subscriptions` + (add/remove channel), `userBlocks` (block/unblock — clean chokepoint at + `authSessionCoordinator.handleProfileBlocklistMutation`), DM send, and reactions + (**#54** — verify like/dislike is this same lost-signer issue vs a dead-button + wiring bug) through `ensureEncryptionCapableSigner`. Each needs its caller's + error-mapping checked so a "cancelled"/"bad-passphrase" reads cleanly (that's why + they weren't batched into the first pass). The `need:"sign"` mode covers + sign-only ops (e.g. reactions) that don't require encryption.