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.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 21:52:44 -04:00
1 parent 7a45f1cf3e
commit 94ef6a72f5
21 files changed
+788 -54

No files matched your search

+33 -3
View File
@@ -49,6 +49,10 @@ export class BitGateProvider extends globalThis.HTMLElement {
/** @type {Error|null} */
this.error = null;
this._ready = false;
// Increments on every start, so a start that was superseded mid-flight can
// tell it lost the race and clean up after itself instead of overwriting
// the newer runtime and leaking its own sockets.
this._generation = 0;
// A promise as well as an event. The event fires during element upgrade,
// which for a page that calls defineBitGateElements() after parsing is
@@ -92,8 +96,13 @@ export class BitGateProvider extends globalThis.HTMLElement {
attributeChangedCallback(name, before, after) {
// Only rebuild once connected and actually changed; the initial attribute
// pass would otherwise construct several runtimes during parsing.
if (this._ready && before !== after) {
//
// `stop()` clears the ready flag, so it is restored here — otherwise the
// first attribute change would leave the guard permanently false and every
// later change would be silently ignored.
if (this._ready && this.isConnected && before !== after) {
this.stop();
this._ready = true;
void this.start();
}
}
@@ -114,6 +123,10 @@ export class BitGateProvider extends globalThis.HTMLElement {
* whatever cached state exists.
*/
async start() {
const generation = (this._generation += 1);
/** @type {any} */
let transport = null;
try {
const relays = this.relayUrls;
const policyName = this.getAttribute("policy") ?? "admin-only";
@@ -128,12 +141,21 @@ export class BitGateProvider extends globalThis.HTMLElement {
throw new Error("bitgate-provider requires a relays attribute");
}
this.transport = createRelayTransport(relays);
transport = createRelayTransport(relays);
// A newer start began while this one was resolving its configuration.
// Close what was just opened rather than clobbering the newer runtime.
if (generation !== this._generation) {
transport.close();
return;
}
this.transport = transport;
this.runtime = createBitGate({
applicationId: this.getAttribute("application") ?? globalThis.location?.hostname ?? "bitgate",
namespace: this.getAttribute("namespace") ?? "bitgate",
root: this.getAttribute("root") ?? undefined,
transport: this.transport,
transport,
policy,
storage: createLocalStorage(),
now: () => Math.floor(Date.now() / 1000),
@@ -156,6 +178,10 @@ export class BitGateProvider extends globalThis.HTMLElement {
}),
);
if (generation !== this._generation) {
return;
}
await this.runtime.loadAdministrativeState();
// With a viewer known, build the trust graph without the host having to:
@@ -168,6 +194,10 @@ export class BitGateProvider extends globalThis.HTMLElement {
this.dispatchEvent(new CustomEvent("bitgate:loaded", { bubbles: true, composed: true }));
} catch (error) {
transport?.close?.();
if (generation !== this._generation) {
return;
}
this.error = /** @type {Error} */ (error);
this.setAttribute("data-error", String(error));
this._rejectReady?.(error);