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

+65
View File
@@ -0,0 +1,65 @@
# @bitgate/verify
NIP-01 event signature verification for BitGate. Optional package, so the rest
of BitGate carries no crypto dependency and can be audited without one.
```bash
npm install @bitgate/verify
```
```js
import { createBitGate } from "@bitgate/runtime";
import { createVerifier } from "@bitgate/verify";
const runtime = createBitGate({
root: ROOT_PUBKEY,
verifySignature: createVerifier(),
…
});
```
`<bitgate-provider>` uses this automatically — the widget bundle includes it, so
the drop-in path is verified without any extra step.
## What verification means here
Two checks, and both matter:
1. **The event id is the hash of the event's own contents.** Without this, an
attacker could take a genuine signature over one id and attach it to
different content.
2. **The signature is valid for that id under the claimed pubkey.**
Checking only the signature would let a forged event pass by reusing a real
signature over an unrelated id. `verifyEvent` recomputes the id and refuses on
mismatch before touching the curve.
## Clock skew
`createVerifier({ maxFutureSeconds })` (default 900) rejects events dated
further ahead than the allowance. 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. Pass `0` to
disable.
## It never throws
Malformed input, bad hex, and curve errors are all "not verified". A verifier
that threw would be a denial-of-service vector: one hostile event would break
the whole ingestion batch.
## Conformance
Validated against **120 live events** from a public relay across kinds 0, 1, 3,
and 10002 — including 59 with non-ASCII content and 71 containing quotes,
newlines, or tabs, which is where serialization mismatches would surface. All
120 verified with zero id mismatches.
## API
| Export | Purpose |
| --- | --- |
| `createVerifier(options)` | Build a verifier for `verifySignature` |
| `verifyEvent(event)` | Verify one event; never throws |
| `computeEventId(event)` | NIP-01 event id |
| `isVerifiable(event)` | Structural check, before any crypto |
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@bitgate/verify",
"version": "0.1.0",
"type": "module",
"description": "Optional NIP-01 event signature verification for BitGate.",
"main": "src/index.js",
"types": "dist/types/index.d.ts",
"files": ["src", "dist"],
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./src/index.js"
},
"./dist/types/*": "./dist/types/*"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@noble/curves": "^1.9.0",
"@noble/hashes": "^1.8.0"
}
}
+147
View File
@@ -0,0 +1,147 @@
// NIP-01 event signature verification.
//
// Optional by design. The rest of BitGate carries no crypto dependency so it
// can run anywhere and be audited without one; this package exists so that
// "supply your own verifier" is not the only path to a secure deployment.
//
// Verification here means two things, and both matter:
//
// 1. The event id is the hash of the event's own contents. Without this an
// attacker could sign one id and attach it to different content.
// 2. The signature is valid for that id under the claimed pubkey.
//
// Checking only the signature would let a forged event pass by reusing a real
// signature over an unrelated id.
import { schnorr } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha2";
/**
* @typedef {Object} NostrEvent
* @property {string} id
* @property {string} pubkey
* @property {number} kind
* @property {number} created_at
* @property {string[][]} tags
* @property {string} content
* @property {string} [sig]
*/
const HEX_64 = /^[0-9a-f]{64}$/;
const HEX_128 = /^[0-9a-f]{128}$/;
const encoder = new TextEncoder();
/**
* @param {Uint8Array} bytes
* @returns {string}
*/
function toHex(bytes) {
let hex = "";
for (const byte of bytes) {
hex += byte.toString(16).padStart(2, "0");
}
return hex;
}
/**
* Compute an event id per NIP-01.
*
* The id is the SHA-256 of the compact JSON serialization of
* `[0, pubkey, created_at, kind, tags, content]`. `JSON.stringify` already
* produces the required form: no whitespace, and the same escaping rules the
* spec lists.
*
* @param {Pick<NostrEvent, "pubkey"|"created_at"|"kind"> & Partial<Pick<NostrEvent, "tags"|"content">>} event
* @returns {string} Lowercase hex digest
*/
export function computeEventId(event) {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags ?? [],
event.content ?? "",
]);
return toHex(sha256(encoder.encode(serialized)));
}
/**
* Whether an event is structurally capable of being verified.
*
* Checked before any crypto so that malformed input is a cheap `false` rather
* than an exception from deep inside a curve implementation.
*
* @param {unknown} event
* @returns {event is NostrEvent}
*/
export function isVerifiable(event) {
if (!event || typeof event !== "object") {
return false;
}
const candidate = /** @type {any} */ (event);
return (
typeof candidate.id === "string" &&
HEX_64.test(candidate.id) &&
typeof candidate.pubkey === "string" &&
HEX_64.test(candidate.pubkey) &&
typeof candidate.sig === "string" &&
HEX_128.test(candidate.sig) &&
typeof candidate.kind === "number" &&
typeof candidate.created_at === "number" &&
typeof candidate.content === "string" &&
Array.isArray(candidate.tags)
);
}
/**
* Verify one event.
*
* Never throws: a malformed event, a bad hex string, or a curve error are all
* "not verified". A verifier that throws would be a denial-of-service vector,
* since a single hostile event would break the whole ingestion batch.
*
* @param {NostrEvent} event
* @returns {boolean}
*/
export function verifyEvent(event) {
if (!isVerifiable(event)) {
return false;
}
try {
// Bind the signature to the content, not merely to the claimed id.
if (computeEventId(event) !== event.id) {
return false;
}
return schnorr.verify(/** @type {string} */ (event.sig), event.id, event.pubkey);
} catch {
return false;
}
}
/**
* Build a verifier for `createBitGate({ verifySignature })`.
*
* @param {Object} [options]
* @param {number} [options.maxFutureSeconds] - Reject events dated further ahead
* than this. A far-future `created_at` wins replaceable-event selection
* forever, pinning stale state in place; the signature is valid, so only a
* clock check catches it. Set 0 to disable.
* @param {() => number} [options.now] - Injected clock, unix seconds
* @returns {(event: NostrEvent) => boolean}
*/
export function createVerifier({ maxFutureSeconds = 900, now } = {}) {
const clock = now ?? (() => Math.floor(Date.now() / 1000));
return (event) => {
if (!verifyEvent(event)) {
return false;
}
if (maxFutureSeconds > 0 && event.created_at > clock() + maxFutureSeconds) {
return false;
}
return true;
};
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { schnorr } from "@noble/curves/secp256k1";
import { computeEventId, createVerifier, isVerifiable, verifyEvent } from "../src/index.js";
const NOW = 1_750_000_000;
// A fixed key so every run signs the same events.
const SECRET = new Uint8Array(32).fill(7);
const PUBKEY = Buffer.from(schnorr.getPublicKey(SECRET)).toString("hex");
/** Build a properly signed event. */
function signEvent(overrides = {}) {
const unsigned = {
pubkey: PUBKEY,
created_at: NOW,
kind: 1,
tags: [["p", "a1".repeat(32)]],
content: "hello",
...overrides,
};
const id = computeEventId(unsigned);
const sig = Buffer.from(schnorr.sign(id, SECRET)).toString("hex");
return { ...unsigned, id, sig };
}
describe("computeEventId", () => {
it("produces a 64-character hex digest", () => {
expect(computeEventId(signEvent())).toMatch(/^[0-9a-f]{64}$/);
});
it("is deterministic", () => {
const event = signEvent();
expect(computeEventId(event)).toBe(computeEventId(event));
});
it("changes when any serialized field changes", () => {
const base = signEvent();
const fields = [
{ content: "different" },
{ created_at: NOW + 1 },
{ kind: 2 },
{ tags: [["p", "b2".repeat(32)]] },
];
for (const change of fields) {
expect(computeEventId({ ...base, ...change }), JSON.stringify(change)).not.toBe(base.id);
}
});
it("treats missing tags and content as empty rather than throwing", () => {
expect(() =>
computeEventId(/** @type {any} */ ({ pubkey: PUBKEY, created_at: NOW, kind: 1 })),
).not.toThrow();
});
});
describe("isVerifiable", () => {
it("accepts a well-formed signed event", () => {
expect(isVerifiable(signEvent())).toBe(true);
});
it("rejects structurally invalid input without touching crypto", () => {
const event = signEvent();
const cases = [
null,
undefined,
"a string",
{ ...event, id: "short" },
{ ...event, pubkey: "not-hex" },
{ ...event, sig: undefined },
{ ...event, sig: "abcd" },
{ ...event, kind: "1" },
{ ...event, created_at: "now" },
{ ...event, tags: "none" },
{ ...event, content: 42 },
];
for (const candidate of cases) {
expect(isVerifiable(/** @type {any} */ (candidate)), JSON.stringify(candidate)).toBe(false);
}
});
it("rejects uppercase hex, since ids are canonically lowercase", () => {
const event = signEvent();
expect(isVerifiable({ ...event, id: event.id.toUpperCase() })).toBe(false);
});
});
describe("verifyEvent", () => {
it("accepts a correctly signed event", () => {
expect(verifyEvent(signEvent())).toBe(true);
});
it("rejects tampered content even though the signature is genuine", () => {
// The heart of it: reusing a real signature over altered content must fail,
// which is why the id is recomputed rather than trusted.
const event = signEvent();
expect(verifyEvent({ ...event, content: "tampered" })).toBe(false);
});
it("rejects an event whose id does not match its contents", () => {
const event = signEvent();
expect(verifyEvent({ ...event, id: "ff".repeat(32) })).toBe(false);
});
it("rejects a signature from a different key", () => {
const event = signEvent();
const otherSecret = new Uint8Array(32).fill(9);
const forged = Buffer.from(schnorr.sign(event.id, otherSecret)).toString("hex");
expect(verifyEvent({ ...event, sig: forged })).toBe(false);
});
it("rejects a signature lifted from another event", () => {
const first = signEvent({ content: "one" });
const second = signEvent({ content: "two" });
expect(verifyEvent({ ...second, sig: first.sig })).toBe(false);
});
it("rejects a claimed pubkey the signature does not belong to", () => {
const event = signEvent();
expect(verifyEvent({ ...event, pubkey: "a1".repeat(32) })).toBe(false);
});
it("never throws on hostile input", () => {
const cases = [null, {}, { id: "zz".repeat(32), pubkey: PUBKEY, sig: "0".repeat(128) }];
for (const candidate of cases) {
expect(() => verifyEvent(/** @type {any} */ (candidate))).not.toThrow();
expect(verifyEvent(/** @type {any} */ (candidate))).toBe(false);
}
});
});
describe("createVerifier", () => {
it("accepts a valid current event", () => {
const verify = createVerifier({ now: () => NOW });
expect(verify(signEvent())).toBe(true);
});
it("rejects an event dated far in the future", () => {
// A far-future created_at wins replaceable selection indefinitely, pinning
// stale state in place. The signature is perfectly valid, so only a clock
// check catches it.
const verify = createVerifier({ now: () => NOW, maxFutureSeconds: 60 });
expect(verify(signEvent({ created_at: NOW + 3600 }))).toBe(false);
});
it("allows modest clock skew", () => {
const verify = createVerifier({ now: () => NOW, maxFutureSeconds: 900 });
expect(verify(signEvent({ created_at: NOW + 120 }))).toBe(true);
});
it("accepts old events, which are legitimate", () => {
const verify = createVerifier({ now: () => NOW });
expect(verify(signEvent({ created_at: NOW - 86_400 * 365 }))).toBe(true);
});
it("can disable the clock check", () => {
const verify = createVerifier({ now: () => NOW, maxFutureSeconds: 0 });
expect(verify(signEvent({ created_at: NOW + 86_400 }))).toBe(true);
});
it("still rejects an invalid signature regardless of timing", () => {
const verify = createVerifier({ now: () => NOW });
expect(verify(signEvent({ content: "x" }) && { ...signEvent(), content: "tampered" })).toBe(false);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": ["src/**/*.js"]
}