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

+2
View File
@@ -250,6 +250,8 @@ describe("contribution round-trip", () => {
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
root: ROOT,
trustUnsignedEvents: true,
});
fresh.admin.setRoles({
root: ROOT,
+4
View File
@@ -76,6 +76,7 @@ function makeRuntime(transport) {
transport,
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
});
runtime.setViewer(VIEWER);
return runtime;
@@ -238,6 +239,7 @@ describe("contact list loading", () => {
transport,
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
});
expect(await runtime.loadContacts()).toBe(0);
});
@@ -275,6 +277,7 @@ describe("the viewer's own private mutes", () => {
signer: signerWithNip44(),
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
});
runtime.setViewer(VIEWER);
@@ -292,6 +295,7 @@ describe("the viewer's own private mutes", () => {
signer: signerWithNip44(),
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
});
runtime.setViewer(VIEWER);
+4 -3
View File
@@ -79,6 +79,7 @@ function makeRuntime({ storage, transport, verifySignature, root = ROOT } = {})
now: () => NOW,
verifySignature,
root,
trustUnsignedEvents: true,
});
}
@@ -331,13 +332,13 @@ describe("community sources", () => {
});
const merged = await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:bitgate:user-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:governance:user-deny:v1", kind: CANONICAL_KIND },
]);
expect(merged).toBe(1);
expect(runtime.admin.state.userDeny.has(`user:${CREATOR}`)).toBe(true);
expect(runtime.admin.state.communitySources.get(`user:${CREATOR}`)).toEqual([
`${CURATOR}:app:bitgate:user-deny:v1`,
`${CURATOR}:app:governance:user-deny:v1`,
]);
});
@@ -357,7 +358,7 @@ describe("community sources", () => {
runtime.admin.setRoles({ root: ROOT, actors: { [CURATOR]: ["curator"] } });
await runtime.loadCommunitySources([
{ curator: CURATOR, identifier: "app:bitgate:event-deny:v1", kind: CANONICAL_KIND },
{ curator: CURATOR, identifier: "app:governance:event-deny:v1", kind: CANONICAL_KIND },
]);
expect(runtime.admin.state.eventDeny.size).toBe(0);
+11 -1
View File
@@ -72,6 +72,8 @@ function makeRuntime(events = []) {
transport,
policy: POLICY,
now: () => NOW,
root: ROOT,
trustUnsignedEvents: true,
});
return { runtime, transport };
}
@@ -132,7 +134,9 @@ describe("construction", () => {
it("changes its storage key when the root authority changes", () => {
const { runtime } = makeRuntime();
const before = runtime.storageKeyFor("admin");
runtime.admin.setRoles({ root: ROOT, actors: {} });
// Rotating the root must invalidate the cache namespace so a new
// administration cannot inherit the previous one's stored state.
runtime.admin.setRoles({ root: CREATOR, actors: {} });
expect(runtime.storageKeyFor("admin")).not.toBe(before);
});
});
@@ -266,6 +270,8 @@ describe("loadAdministrativeState", () => {
transport,
policy: POLICY,
now: () => NOW,
root: ROOT,
trustUnsignedEvents: true,
});
await runtime.loadAdministrativeState({ authors: [MODERATOR] });
@@ -284,6 +290,8 @@ describe("active targets and subscriptions", () => {
policy: POLICY,
now: () => NOW,
chunkSize: 2,
root: ROOT,
trustUnsignedEvents: true,
});
runtime.setActiveTargets([
@@ -305,6 +313,8 @@ describe("active targets and subscriptions", () => {
transport,
policy: POLICY,
now: () => NOW,
root: ROOT,
trustUnsignedEvents: true,
});
runtime.setActiveTargets([