mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
135 lines
4.6 KiB
JavaScript
135 lines
4.6 KiB
JavaScript
// 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" });
|
|
});
|