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