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

+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+41 -1
View File
@@ -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
View File
@@ -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",
+60 -6
View File
@@ -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;
}
}
+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"]
}
+2 -1
View File
@@ -21,6 +21,7 @@
},
"dependencies": {
"@bitgate/core": "*",
"@bitgate/runtime": "*"
"@bitgate/runtime": "*",
"@bitgate/verify": "*"
}
}
+31 -1
View File
@@ -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);
+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);
});
});