Files
bitgate/docs/security.md
T
thePR0M3TH3AN 94ef6a72f5 fix(security): close every finding from the audit
Critical
- Any pubkey could publish a kind:30078 roles document and become root of a
  deployment, granting itself every capability and denying anyone. Roles are now
  accepted only from the configured root, compared against the constructor
  value rather than current authority state so a bad ingest cannot move the
  goalposts. Reproduced end to end before the fix; regression test added.
- Administrative state now fails closed without signature verification. Without
  it, event.pubkey is an unauthenticated claim, so an authorship check alone
  proves nothing — a relay can put the root's key on an event the root never
  wrote. trustUnsignedEvents opts in for local and test use.

High
- Report categories could evade moderation entirely: resolveThresholds used
  bracket truthiness, so "constructor" resolved to the Object constructor (all
  gates undefined) and "__proto__" to Object.prototype, both bypassing the
  default threshold. Own-property lookups and null-prototype tables throughout
  core — policy, evaluator, evidence, authority, presets.
- Live subscriptions bypassed signature verification entirely; an attacker only
  had to wait for the subscription rather than the load.
- The default administrative query asked relays for every kind:30078 and
  kind:30000 event in existence, with no author, identifier, or limit. Now
  filtered to governance identifiers with a bounded limit.

Medium
- Cached state was trusted as authority: a cache naming a different root is now
  rejected, so same-origin tampering cannot persist a takeover.
- Provider attribute changes silently stopped working after the first one, and
  concurrent starts leaked a transport. Generation-guarded.
- Unbounded growth in the report store, mute store, decision cache, and the
  transport's per-subscription dedupe set — all now capped.

Also restores the on-wire `d` identifier to <namespace>:governance:<scope>:v1.
The BitGate rename replaced "governance:" too broadly and silently changed the
event format; both encoder and parser changed together, so round-trip tests
never noticed. The identifier names the kind of document, not the library.

Adds docs/security.md and 21 regression tests reproducing each finding.

832 tests.
2026-08-09 21:52:44 -04:00

4.1 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.

createBitGate({
  root: ROOT_PUBKEY,
  verifySignature: (event) => verifyEventSignature(event),   // your crypto library
  …
});

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.