[auth] Re-unlock the signer on demand instead of blanket-erroring encryption ops (#57)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 19:11:19 -04:00
1 parent 633e946325
commit 1b79ab9d09
7 files changed
+442

No files matched your search

+100
View File
@@ -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;
+56
View File
@@ -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: <string> } — 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;
+5
View File
@@ -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) =>
@@ -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()
+134
View File
@@ -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" });
});
+87
View File
@@ -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');
});
+21
View File
@@ -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.