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

+29 -2
View File
@@ -231,10 +231,18 @@ export class TrustGraphStore extends Emitter {
* NIP-56 reports, aggregated per target.
*/
export class ReportStore extends Emitter {
constructor() {
/**
* @param {Object} [options]
* @param {number} [options.maxTargets] - Ceiling on distinct targets retained
*/
constructor({ maxTargets = 0 } = {}) {
super();
/** @type {Map<string, Map<string, Map<string, number>>>} reports[targetKey][reporter][category] = createdAt */
this.reports = new Map();
// Relays push reports for targets this viewer may never look at again.
// Without a ceiling a long-running session accumulates every target ever
// mentioned.
this.maxTargets = maxTargets;
this.fingerprint = fingerprint({});
}
@@ -275,6 +283,14 @@ export class ReportStore extends Emitter {
}
byCategory.set(report.category, createdAt);
if (this.maxTargets > 0 && this.reports.size > this.maxTargets) {
const oldest = this.reports.keys().next().value;
if (oldest !== undefined && oldest !== targetKey) {
this.reports.delete(oldest);
}
}
this.#emitChange(targetKey);
}
@@ -333,11 +349,14 @@ export class TrustedMuteStore extends Emitter {
* @param {Object} [options]
* @param {number} [options.windowSeconds] - Mutes older than this are pruned
* @param {() => number} [options.now] - Injected clock, unix seconds
* @param {number} [options.maxLists] - Ceiling on retained lists; 0 disables eviction
*/
constructor({ windowSeconds = 0, now = () => 0 } = {}) {
constructor({ windowSeconds = 0, now = () => 0, maxLists = 0 } = {}) {
super();
this.windowSeconds = windowSeconds;
this.now = now;
/** Ceiling on retained lists; 0 disables eviction. */
this.maxLists = maxLists;
/** @type {Map<string, { owner: string, updatedAt: number, entries: Array<{ pubkey: string, category?: string }> }>} */
this.lists = new Map();
this.fingerprint = fingerprint({});
@@ -372,6 +391,14 @@ export class TrustedMuteStore extends Emitter {
updatedAt: list.updatedAt,
entries: list.entries,
});
if (this.maxLists > 0 && this.lists.size > this.maxLists) {
const oldest = this.lists.keys().next().value;
if (oldest !== undefined && oldest !== list.owner) {
this.lists.delete(oldest);
}
}
this.#emitChange(Array.from(affected));
}