mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 16:41:03 +00:00
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.
104 lines
4.8 KiB
Markdown
104 lines
4.8 KiB
Markdown
# 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:
|
|
|
|
```js
|
|
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.
|