mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-02 19:41:00 +00:00
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:
1 parent
94ef6a72f5
commit
3b95a63058
14 files changed
+596
-12
No files matched your search
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user