feat(verify): optional NIP-01 signature verification, on by default in the widget

Closes the gap the audit left open: administrative state fails closed without a
verifier, and BitGate shipped none.

@bitgate/verify is a separate package so core, nostr, and runtime keep no crypto
dependency and can be audited without one. The widget bundles it, so the
drop-in path is verified with no extra step; verify="off" disables it, sets
data-unverified on the element, and emits bitgate:unverified — meant for local
development, and meant to be visible.

Verification recomputes the event id before checking the signature. Checking
only the signature would let a forged event pass by reusing a genuine signature
over an unrelated id, which is the failure mode that matters here.

createVerifier also rejects events dated far in the future. A far-future
created_at wins replaceable-event selection indefinitely and pins stale state in
place; the signature is perfectly valid, so only a clock check catches it.

Found while testing: #adminIngestAllowed checked that a verifier *exists*, not
that this event was verified — so the public ingestEvent path, which
"bring your own relays" integrations use, bypassed verification entirely.
Verification now runs inside ingestEvent, with an already-verified flag for the
loaders. An async verifier cannot be awaited there and is treated as
unverified; ingestVerified() awaits properly.

Conformance validated against 120 live events from a public relay across kinds
0, 1, 3 and 10002 — 59 with non-ASCII content and 71 containing quotes,
newlines or tabs, which is where a serialization mismatch would surface. All
120 verified, zero id mismatches.

Bundle grows 72K -> 120K. 855 tests; audit clean.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-10 08:34:12 -04:00
1 parent 94ef6a72f5
commit 3b95a63058
14 files changed
+596 -12

No files matched your search

+29
View File
@@ -273,3 +273,32 @@ describe("context discovery", () => {
expect(veil.runtime).toBe(provider.runtime);
});
});
describe("signature verification posture", () => {
it("verifies by default", async () => {
const provider = await mountProvider();
expect(provider.runtime.describe().signatureVerification).toBe("enabled");
expect(provider.hasAttribute("data-unverified")).toBe(false);
});
it("rejects a forged administrative event by default", async () => {
const provider = await mountProvider();
// Structurally plausible but unsigned — exactly what a hostile relay sends.
const accepted = provider.runtime.ingestEvent({
id: "ff".repeat(32),
pubkey: ROOT,
kind: 30078,
created_at: 1_750_000_000,
tags: [["d", "bitgate:governance:roles:v1"]],
content: "",
sig: "00".repeat(64),
});
expect(accepted).toBe(false);
});
it("can be disabled explicitly, and says so", async () => {
const provider = await mountProvider({ verify: "off" });
expect(provider.runtime.describe().signatureVerification).toBe("disabled");
expect(provider.hasAttribute("data-unverified")).toBe(true);
});
});