Files
bitgate/docs/security.md
T
thePR0M3TH3AN 3b95a63058 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.
2026-08-10 08:34:12 -04:00

4.8 KiB

Security model

What BitGate assumes, what it enforces, and what it cannot do for you.

The trust boundary

Everything arriving from a relay is untrusted input. Relays are transport, not authority: a relay can withhold events, replay old ones, and — absent signature verification — fabricate them wholesale, including the pubkey field.

Two independent checks gate administrative state:

  1. Authorship. A roles document is accepted only from the deployment's configured root. Comparison is against configuredRoot — the value passed to the constructor — never against the current authority state, so a bad ingest cannot move the goalposts for the next one.
  2. Authenticity. event.pubkey is an unauthenticated claim until a signature is checked, so an authorship check alone proves nothing.

Signature verification is required

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:

import { createVerifier } from "@bitgate/verify";

createBitGate({
  root: ROOT_PUBKEY,
  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.

Trust signals — reports and mutes — are not gated this way, because they are bounded by the viewer's own follow graph rather than by authority. A forged report only counts if it appears to come from someone the viewer already follows. That is a weaker guarantee than the administrative path and is the reason verification still matters for them.

Hostile input BitGate is designed to survive

Attack Defence
Publishing a roles document to become root Authorship check against configured root
A relay forging an event from the root's key Signature verification; fails closed without it
Report category crafted to dodge thresholds (constructor, __proto__) Own-property lookups and null-prototype tables throughout
Denied account manufacturing reports Denied and blocked accounts are excluded before aggregation
One account reporting repeatedly Deduplicated per (reporter, category)
Curator exceeding their remit Capabilities resolved at merge time, per contribution kind
Denying the root or a protected actor Protected actors stripped from every denial set, and refused at the command boundary
Same-origin script tampering with cached state A cached roster naming a different root is rejected outright
Stale relay data holding a target down forever Configurable mute validity window
Unbounded relay responses Administrative queries filter on governance d identifiers and carry a limit

What BitGate does not do

It never handles secret material. Signing and decryption are delegated to an injected signer. There is no code path that reads a private key.

It cannot decrypt another account's private mutes, and does not try. Only the viewer's own list is decrypted, and the check lives at the codec boundary rather than depending on callers to behave.

It does not moderate relays. BitGate governs what a client shows; what a relay stores is a different layer (see NIP-86).

It is not a rate limiter. Report and mute stores carry ceilings to bound memory, not to resist flooding. A relay that floods you will evict your own cache first.

Operational guidance

  • Configure root explicitly. Without it, every roster is refused.
  • Configure verifySignature. Without it, all administrative state is refused.
  • Treat describe() as a health check: signatureVerification, stale, rejectedUnauthorized, and rejectedUnverified all indicate posture.
  • Rotating the root invalidates cached state by design — the storage key is namespaced by root identity.
  • Serve the widget from your own origin, built from source you have read.

Reporting a vulnerability

Open a private security advisory on the repository rather than a public issue.