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.
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:
- Authorship. A roles document is accepted only from the deployment's
configured
root. Comparison is againstconfiguredRoot— the value passed to the constructor — never against the current authority state, so a bad ingest cannot move the goalposts for the next one. - Authenticity.
event.pubkeyis 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
rootexplicitly. Without it, every roster is refused. - Configure
verifySignature. Without it, all administrative state is refused. - Treat
describe()as a health check:signatureVerification,stale,rejectedUnauthorized, andrejectedUnverifiedall 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.