diff --git a/README.md b/README.md index a65bf92..84b86f0 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/docs/nips.md b/docs/nips.md index d0f8a48..1c3c352 100644 --- a/docs/nips.md +++ b/docs/nips.md @@ -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 | diff --git a/docs/security.md b/docs/security.md index e29b757..abdf2a0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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(), … }); ``` +`` 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. diff --git a/package-lock.json b/package-lock.json index 88e88e7..a157d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*" } } } diff --git a/package.json b/package.json index 35a7f8a..4ddb5a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/runtime/src/runtime.js b/packages/runtime/src/runtime.js index 3587d9a..0bcd0e1 100644 --- a/packages/runtime/src/runtime.js +++ b/packages/runtime/src/runtime.js @@ -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} + */ + 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; } } diff --git a/packages/verify/README.md b/packages/verify/README.md new file mode 100644 index 0000000..d61cbf9 --- /dev/null +++ b/packages/verify/README.md @@ -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(), + … +}); +``` + +`` 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 | diff --git a/packages/verify/package.json b/packages/verify/package.json new file mode 100644 index 0000000..c3377ac --- /dev/null +++ b/packages/verify/package.json @@ -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" + } +} diff --git a/packages/verify/src/index.js b/packages/verify/src/index.js new file mode 100644 index 0000000..614a815 --- /dev/null +++ b/packages/verify/src/index.js @@ -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 & Partial>} 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; + }; +} diff --git a/packages/verify/test/verify.test.js b/packages/verify/test/verify.test.js new file mode 100644 index 0000000..369274c --- /dev/null +++ b/packages/verify/test/verify.test.js @@ -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); + }); +}); diff --git a/packages/verify/tsconfig.types.json b/packages/verify/tsconfig.types.json new file mode 100644 index 0000000..384c9af --- /dev/null +++ b/packages/verify/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": ["src/**/*.js"] +} diff --git a/packages/widget/package.json b/packages/widget/package.json index 7509c64..ee532a4 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@bitgate/core": "*", - "@bitgate/runtime": "*" + "@bitgate/runtime": "*", + "@bitgate/verify": "*" } } diff --git a/packages/widget/src/provider.js b/packages/widget/src/provider.js index 06df463..24e8765 100644 --- a/packages/widget/src/provider.js +++ b/packages/widget/src/provider.js @@ -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); diff --git a/packages/widget/test/provider.test.js b/packages/widget/test/provider.test.js index c1cea77..5ed2c9d 100644 --- a/packages/widget/test/provider.test.js +++ b/packages/widget/test/provider.test.js @@ -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); + }); +});