mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31: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
@@ -46,6 +46,7 @@ equivalent to following the operator's seed list.
|
||||
| `@bitgate/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
|
||||
| `@bitgate/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
|
||||
| `@bitgate/runtime` | Stores, transport orchestration, commands. |
|
||||
| `@bitgate/verify` | Optional NIP-01 signature verification, so the core stays dependency-free. |
|
||||
| `@bitgate/testing` | Conformance harness. |
|
||||
| `@bitgate/widget` | Drop-in custom elements: viewer controls and a moderator console. |
|
||||
| `@bitgate/bitvid-compat` | Reference application profile for the characterization corpus. |
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ this table for anything load-bearing.
|
||||
|
||||
| NIP | Kind | How BitGate uses it |
|
||||
| --- | --- | --- |
|
||||
| **NIP-01** | — | Event shape, replaceable and parameterized-replaceable selection with a deterministic same-timestamp tiebreak |
|
||||
| **NIP-01** | — | Event shape, replaceable selection with a deterministic same-timestamp tiebreak, and signature verification via `@bitgate/verify` (id recomputed, then schnorr-checked) |
|
||||
| **NIP-02** | 3 | The viewer's follow list *is* the trust graph. Relay hints in the third tag position are captured as an outbox fallback. |
|
||||
| **NIP-19** | — | bech32 `npub`/`note` decoding at the codec edge, so the core stays hex-only |
|
||||
| **NIP-32** | 1985 | **Not yet** — see gaps below |
|
||||
|
||||
+17
-1
@@ -23,14 +23,30 @@ Administrative state **fails closed** without it. With no `verifySignature`
|
||||
configured, roles, policy, and contribution documents are rejected and counted
|
||||
in `diagnostics.rejectedUnverified`.
|
||||
|
||||
Use the optional package, or supply your own:
|
||||
|
||||
```js
|
||||
import { createVerifier } from "@bitgate/verify";
|
||||
|
||||
createBitGate({
|
||||
root: ROOT_PUBKEY,
|
||||
verifySignature: (event) => verifyEventSignature(event), // your crypto library
|
||||
verifySignature: createVerifier(),
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
`<bitgate-provider>` does this automatically. Verification can be switched off
|
||||
with `verify="off"`, which sets `data-unverified` on the element and emits
|
||||
`bitgate:unverified` — it is meant for local development, and it is meant to be
|
||||
visible.
|
||||
|
||||
Verification runs inside `ingestEvent` itself, not only in the loaders, because
|
||||
`ingestEvent` is public API: an application feeding events from its own relay
|
||||
pool would otherwise get none. An asynchronous verifier cannot be awaited on
|
||||
that synchronous path and is treated as unverified — use `ingestVerified()` or
|
||||
the loaders, both of which await properly. Failing closed is the only safe
|
||||
reading of "I could not check this".
|
||||
|
||||
`trustUnsignedEvents: true` exists for local development and tests, where
|
||||
events are constructed in-process and there is no relay to lie. Never set it in
|
||||
production; `describe().signatureVerification` reports the posture.
|
||||
|
||||
Generated
+41
-1
@@ -58,6 +58,10 @@
|
||||
"resolved": "packages/testing",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitgate/verify": {
|
||||
"resolved": "packages/verify",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@bitgate/widget": {
|
||||
"resolved": "packages/widget",
|
||||
"link": true
|
||||
@@ -528,6 +532,33 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
||||
@@ -1813,12 +1844,21 @@
|
||||
"@bitgate/core": "*"
|
||||
}
|
||||
},
|
||||
"packages/verify": {
|
||||
"name": "@bitgate/verify",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.9.0",
|
||||
"@noble/hashes": "^1.8.0"
|
||||
}
|
||||
},
|
||||
"packages/widget": {
|
||||
"name": "@bitgate/widget",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bitgate/core": "*",
|
||||
"@bitgate/runtime": "*"
|
||||
"@bitgate/runtime": "*",
|
||||
"@bitgate/verify": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"test:unit": "vitest run --passWithNoTests",
|
||||
"test:conformance": "vitest run tests/conformance --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/widget/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json",
|
||||
"build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/verify/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/widget/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json",
|
||||
"lint": "npm run check:references && npm run check:types",
|
||||
"check:references": "node scripts/verify-no-reference-imports.mjs",
|
||||
"check:types": "tsc -p tsconfig.json --noEmit && tsc -p packages/widget/tsconfig.json --noEmit",
|
||||
|
||||
@@ -335,11 +335,21 @@ export class GovernanceRuntime extends Emitter {
|
||||
* @param {NostrEvent} event
|
||||
* @returns {boolean} Whether the event was recognized
|
||||
*/
|
||||
ingestEvent(event) {
|
||||
ingestEvent(event, { verified = false } = {}) {
|
||||
if (!event || typeof event.kind !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verification here rather than only in the loaders: ingestEvent is public,
|
||||
// and an application feeding events from its own relay pool — a documented
|
||||
// use case — would otherwise get none. Internal callers that have already
|
||||
// verified pass `verified: true` to avoid checking twice.
|
||||
if (!verified && !this.#passesVerification(event)) {
|
||||
this.diagnostics.rejectedSignatures += 1;
|
||||
this.emit("rejected", { reason: "invalid-signature", id: event.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
this.diagnostics.eventsIngested += 1;
|
||||
|
||||
if (event.kind === REPORT_KIND) {
|
||||
@@ -463,6 +473,50 @@ export class GovernanceRuntime extends Emitter {
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/**
|
||||
* Run the configured verifier synchronously.
|
||||
*
|
||||
* A verifier that returns a promise cannot be awaited on this path, so it is
|
||||
* treated as unverified: callers with an async verifier must use the loaders
|
||||
* or `ingestVerified()`, both of which await properly. Failing closed is the
|
||||
* only safe reading of "I could not check this".
|
||||
*
|
||||
* @param {NostrEvent} event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#passesVerification(event) {
|
||||
if (!this.verifySignature) {
|
||||
// No verifier configured: trust signals still flow, and administrative
|
||||
// documents are refused separately by #adminIngestAllowed().
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const result = this.verifySignature(event);
|
||||
if (typeof result === "boolean") {
|
||||
return result;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest an event, awaiting an asynchronous verifier when one is configured.
|
||||
* @param {NostrEvent} event
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async ingestVerified(event) {
|
||||
if (this.verifySignature) {
|
||||
const [ok] = await this.#verified([event]);
|
||||
if (!ok) {
|
||||
this.diagnostics.rejectedSignatures += 1;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return this.ingestEvent(event, { verified: true });
|
||||
}
|
||||
|
||||
#adminIngestAllowed() {
|
||||
if (this.verifySignature || this.trustUnsignedEvents) {
|
||||
return true;
|
||||
@@ -603,7 +657,7 @@ export class GovernanceRuntime extends Emitter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return this.ingestEvent(event);
|
||||
return this.ingestEvent(event, { verified: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -678,7 +732,7 @@ export class GovernanceRuntime extends Emitter {
|
||||
|
||||
const effective = Array.from(selectReplaceable(await this.#verified(events)).values());
|
||||
for (const event of effective) {
|
||||
this.ingestEvent(event);
|
||||
this.ingestEvent(event, { verified: true });
|
||||
}
|
||||
|
||||
this.stale = false;
|
||||
@@ -757,7 +811,7 @@ export class GovernanceRuntime extends Emitter {
|
||||
}
|
||||
|
||||
for (const event of selectReplaceable(await this.#verified(events)).values()) {
|
||||
if (this.ingestEvent(event)) {
|
||||
if (this.ingestEvent(event, { verified: true })) {
|
||||
learned += 1;
|
||||
}
|
||||
}
|
||||
@@ -787,7 +841,7 @@ export class GovernanceRuntime extends Emitter {
|
||||
}
|
||||
|
||||
for (const event of selectReplaceable(await this.#verified(events)).values()) {
|
||||
this.ingestEvent(event);
|
||||
this.ingestEvent(event, { verified: true });
|
||||
}
|
||||
|
||||
return this.trust.contacts.size;
|
||||
@@ -838,7 +892,7 @@ export class GovernanceRuntime extends Emitter {
|
||||
}
|
||||
|
||||
for (const event of selectReplaceable(await this.#verified(events)).values()) {
|
||||
if (this.ingestEvent(event)) {
|
||||
if (this.ingestEvent(event, { verified: true })) {
|
||||
ingested += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.js"]
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitgate/core": "*",
|
||||
"@bitgate/runtime": "*"
|
||||
"@bitgate/runtime": "*",
|
||||
"@bitgate/verify": "*"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createBitGate, createCommands, createRelayTransport } from "@bitgate/runtime";
|
||||
import { getPolicyPreset } from "@bitgate/core";
|
||||
import { createVerifier } from "@bitgate/verify";
|
||||
|
||||
import { GovernanceElement, defineElement, escapeHtml } from "./base.js";
|
||||
|
||||
@@ -35,7 +36,17 @@ export function requestContext(element) {
|
||||
|
||||
export class BitGateProvider extends globalThis.HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["relays", "root", "policy", "profile", "application", "namespace", "viewer", "trust"];
|
||||
return [
|
||||
"relays",
|
||||
"root",
|
||||
"policy",
|
||||
"profile",
|
||||
"application",
|
||||
"namespace",
|
||||
"viewer",
|
||||
"trust",
|
||||
"verify",
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -150,6 +161,23 @@ export class BitGateProvider extends globalThis.HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verified by default. A relay can otherwise put the root's key on an
|
||||
// event the root never wrote, and administrative state would fail closed
|
||||
// anyway — so the secure path has to be the one that needs no opt-in.
|
||||
const verificationDisabled = this.getAttribute("verify") === "off";
|
||||
if (verificationDisabled) {
|
||||
this.setAttribute("data-unverified", "");
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("bitgate:unverified", {
|
||||
detail: { reason: 'verify="off"' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.removeAttribute("data-unverified");
|
||||
}
|
||||
|
||||
this.transport = transport;
|
||||
this.runtime = createBitGate({
|
||||
applicationId: this.getAttribute("application") ?? globalThis.location?.hostname ?? "bitgate",
|
||||
@@ -159,6 +187,8 @@ export class BitGateProvider extends globalThis.HTMLElement {
|
||||
policy,
|
||||
storage: createLocalStorage(),
|
||||
now: () => Math.floor(Date.now() / 1000),
|
||||
verifySignature: verificationDisabled ? undefined : createVerifier(),
|
||||
trustUnsignedEvents: verificationDisabled,
|
||||
});
|
||||
this.commands = createCommands(this.runtime);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user