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

+87
View File
@@ -0,0 +1,87 @@
# 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`.
```js
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 `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.
+3 -1
View File
@@ -94,7 +94,9 @@ export function reduceAdminState(contributions, authority) {
continue;
}
const capability = KIND_CAPABILITY[contribution.kind];
const capability = Object.hasOwn(KIND_CAPABILITY, contribution.kind)
? KIND_CAPABILITY[contribution.kind]
: undefined;
if (!capability) {
continue;
}
+6 -3
View File
@@ -102,8 +102,10 @@ export function isGovernanceCapability(value) {
* @returns {AuthorityState}
*/
export function createAuthorityState(options = {}) {
// Role names arrive from published rosters; a null prototype keeps a
// "__proto__" role from mutating the object instead of being stored.
/** @type {Record<string, readonly GovernanceCapability[]>} */
const roles = {};
const roles = Object.create(null);
for (const [name, capabilities] of Object.entries(options.roles ?? DEFAULT_ROLE_CAPABILITIES)) {
const trimmed = name.trim();
if (!trimmed) {
@@ -115,7 +117,7 @@ export function createAuthorityState(options = {}) {
}
/** @type {Record<string, string[]>} */
const actors = {};
const actors = Object.create(null);
for (const [pubkey, roleNames] of Object.entries(options.actors ?? {})) {
const normalized = normalizePubkey(pubkey);
if (!normalized) {
@@ -187,7 +189,8 @@ export function getActorCapabilities(pubkey, authority) {
/** @type {Set<GovernanceCapability>} */
const capabilities = new Set();
for (const roleName of authority.actors[normalized] ?? []) {
for (const capability of authority.roles[roleName] ?? []) {
const granted = Object.hasOwn(authority.roles, roleName) ? authority.roles[roleName] : [];
for (const capability of granted) {
capabilities.add(capability);
}
}
+7 -3
View File
@@ -153,8 +153,11 @@ export function aggregateReports(key, snapshot, viewer, trustSet) {
reporters.add(record.reporter);
}
// Null prototype: categories come from report events, so a "__proto__" key
// on a plain object would be silently dropped from the map while still
// counting toward the total.
/** @type {Record<string, number>} */
const counts = {};
const counts = Object.create(null);
let total = 0;
for (const [category, set] of byCategory.entries()) {
counts[category] = set.size;
@@ -209,7 +212,7 @@ export function aggregateMutes(key, snapshot, viewer, trustSet, now, windowSecon
}
/** @type {Record<string, number>} */
const counts = {};
const counts = Object.create(null);
for (const [category, set] of byCategory.entries()) {
counts[category] = set.size;
}
@@ -404,7 +407,8 @@ export function evaluateTarget(target, snapshot, context = { surface: "default"
applyEffects(decision, effects, reasons);
for (const [category, count] of Object.entries(mutes.byCategory)) {
const categoryThresholds = profile.mutes?.[category];
const categoryThresholds =
profile.mutes && Object.hasOwn(profile.mutes, category) ? profile.mutes[category] : undefined;
if (!categoryThresholds) {
continue;
}
+15 -8
View File
@@ -38,10 +38,13 @@
export function createEmptyEvidence() {
return {
trustedReportTotal: 0,
trustedReportsByCategory: {},
// Null prototype throughout: categories originate in report and mute
// events, so a "__proto__" key on a normal object literal would be
// silently discarded while still counting toward the total.
trustedReportsByCategory: Object.create(null),
trustedReporterPubkeys: [],
trustedMuteTotal: 0,
trustedMutesByCategory: {},
trustedMutesByCategory: Object.create(null),
trustedMuterPubkeys: [],
personalBlock: false,
personalMute: false,
@@ -65,13 +68,13 @@ export function createEmptyEvidence() {
*/
export function freezeEvidence(evidence) {
/** @type {Record<string, number>} */
const reportsByCategory = {};
const reportsByCategory = Object.create(null);
for (const key of Object.keys(evidence.trustedReportsByCategory).sort()) {
reportsByCategory[key] = evidence.trustedReportsByCategory[key];
}
/** @type {Record<string, number>} */
const mutesByCategory = {};
const mutesByCategory = Object.create(null);
for (const key of Object.keys(evidence.trustedMutesByCategory).sort()) {
mutesByCategory[key] = evidence.trustedMutesByCategory[key];
}
@@ -112,12 +115,16 @@ export function mergeEvidence(records) {
}
for (const [category, count] of Object.entries(record.trustedReportsByCategory ?? {})) {
merged.trustedReportsByCategory[category] =
(merged.trustedReportsByCategory[category] ?? 0) + count;
const previous = Object.hasOwn(merged.trustedReportsByCategory, category)
? merged.trustedReportsByCategory[category]
: 0;
merged.trustedReportsByCategory[category] = previous + count;
}
for (const [category, count] of Object.entries(record.trustedMutesByCategory ?? {})) {
merged.trustedMutesByCategory[category] =
(merged.trustedMutesByCategory[category] ?? 0) + count;
const previous = Object.hasOwn(merged.trustedMutesByCategory, category)
? merged.trustedMutesByCategory[category]
: 0;
merged.trustedMutesByCategory[category] = previous + count;
}
merged.trustedReportTotal += record.trustedReportTotal ?? 0;
+19 -8
View File
@@ -230,8 +230,11 @@ export function normalizeCategoryThresholds(thresholds, location = "profile") {
return {};
}
// Null prototype: these keys come from policy documents, which may be
// relay-supplied. `normalized["__proto__"] = …` on a normal object literal
// would mutate the prototype rather than adding a key.
/** @type {Record<string, CategoryThresholds>} */
const normalized = {};
const normalized = Object.create(null);
for (const [category, config] of Object.entries(thresholds)) {
if (!config || typeof config !== "object") {
@@ -243,7 +246,7 @@ export function normalizeCategoryThresholds(thresholds, location = "profile") {
}
/** @type {CategoryThresholds} */
const entry = {};
const entry = Object.create(null);
for (const [gate, value] of Object.entries(config)) {
if (!Number.isFinite(value)) {
continue;
@@ -334,7 +337,7 @@ export function normalizePolicyDefinition(policy) {
}
/** @type {Record<string, PolicyProfile>} */
const profiles = {};
const profiles = Object.create(null);
for (const [key, profile] of Object.entries(policy.profiles)) {
const normalizedProfile = normalizePolicyProfile({ ...profile, name: profile?.name ?? key });
profiles[key.trim()] = normalizedProfile;
@@ -369,13 +372,15 @@ export function normalizePolicyDefinition(policy) {
export function resolveProfile(policy, context) {
const requested = context?.policyProfile?.trim();
if (requested) {
const profile = policy.profiles[requested];
if (!profile) {
if (!Object.hasOwn(policy.profiles, requested)) {
throw new Error(`Unknown policy profile: ${requested}`);
}
return profile;
return policy.profiles[requested];
}
const fallback = policy.defaultProfile ?? Object.keys(policy.profiles)[0];
if (!Object.hasOwn(policy.profiles, fallback)) {
throw new Error(`Unknown policy profile: ${fallback}`);
}
return policy.profiles[fallback];
}
@@ -390,10 +395,16 @@ export function resolveThresholds(table, category) {
return {};
}
const normalizedCategory = typeof category === "string" ? category.trim().toLowerCase() : "";
if (normalizedCategory && table[normalizedCategory]) {
// Own-property checks, not bracket truthiness. A report category is
// attacker-supplied, and `table["constructor"]` would otherwise resolve to
// the Object constructor — a truthy value whose gates are all undefined,
// which silently exempts that category from every threshold including the
// default. `__proto__` does the same via Object.prototype.
if (normalizedCategory && Object.hasOwn(table, normalizedCategory)) {
return table[normalizedCategory];
}
return table.default ?? {};
return Object.hasOwn(table, "default") ? table.default : {};
}
/**
+4 -1
View File
@@ -266,5 +266,8 @@ export function getPolicyPreset(name) {
if (typeof name !== "string") {
return null;
}
return POLICY_PRESETS[name.trim().toLowerCase()] ?? null;
const key = name.trim().toLowerCase();
// Own-property check: POLICY_PRESETS["constructor"] would otherwise hand back
// the Object constructor as if it were a policy.
return Object.hasOwn(POLICY_PRESETS, key) ? POLICY_PRESETS[key] : null;
}
+9 -3
View File
@@ -31,13 +31,19 @@ const SCOPE_TO_KIND = {
};
/**
* Build a canonical `d` identifier.
* Build a canonical `d` identifier: `<namespace>:governance:<scope>:v1`.
*
* The middle segment is deliberately the generic word rather than the product
* name. The identifier describes the kind of document, not the library that
* wrote it — another implementation should be able to publish and read the
* same events without adopting our branding.
*
* @param {string} namespace - Application namespace, e.g. "bitroad"
* @param {string} scope - e.g. "user-deny"
* @returns {string}
*/
export function canonicalIdentifier(namespace, scope) {
return `${namespace}:bitgate:${scope}:v${CANONICAL_VERSION}`;
return `${namespace}:governance:${scope}:v${CANONICAL_VERSION}`;
}
/**
@@ -49,7 +55,7 @@ export function parseIdentifier(identifier) {
if (typeof identifier !== "string") {
return null;
}
const match = /^([^:]+):bitgate:([^:]+):v(\d+)$/.exec(identifier.trim());
const match = /^([^:]+):governance:([^:]+):v(\d+)$/.exec(identifier.trim());
if (!match) {
return null;
}
+1 -1
View File
@@ -37,7 +37,7 @@ const event = (parts) => ({
describe("canonical identifiers", () => {
it("builds a namespaced identifier", () => {
expect(canonicalIdentifier("bitroad", "address-deny")).toBe(
"bitroad:bitgate:address-deny:v1",
"bitroad:governance:address-deny:v1",
);
});
+1 -1
View File
@@ -170,7 +170,7 @@ describe("decodeMixedContributions", () => {
const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] });
const canonical = event({
kind: 30078,
tags: [["d", "app:bitgate:user-deny:v1"], ["p", CREATOR]],
tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]],
});
const contributions = decodeMixedContributions([legacy, canonical], decodeContribution);
+2 -2
View File
@@ -281,7 +281,7 @@ export class GovernanceCommands {
kind: 30078,
content: JSON.stringify(policy),
tags: [
["d", `${this.runtime.namespace}:bitgate:policy:v1`],
["d", `${this.runtime.namespace}:governance:policy:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "policy"],
@@ -307,7 +307,7 @@ export class GovernanceCommands {
}
const tags = [
["d", `${this.runtime.namespace}:bitgate:community-sources:v1`],
["d", `${this.runtime.namespace}:governance:community-sources:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "community-sources"],
+1
View File
@@ -23,6 +23,7 @@
/**
* @typedef {Object} ListOptions
* @property {number} [timeout]
* @property {number} [maxSeen] - Ceiling on the per-subscription dedupe window
* @property {string[]} [relays] - Query only these relays, connecting if needed.
* This is what makes outbox-model fetching possible: a contact's mute list
* lives on their write relays, not on this deployment's.
+11
View File
@@ -316,6 +316,11 @@ export function createRelayTransport(urls, options = {}) {
subscribe(filters, handlers, subscribeOptions = {}) {
const id = `bg-sub-${(subscriptionCounter += 1)}`;
const targets = resolveTargets(subscribeOptions.relays);
// Bounded: a long-lived subscription on a busy relay would otherwise
// retain every event id it has ever seen for the life of the page. The
// window only needs to be wide enough to catch the same event arriving
// from several relays at once.
const maxSeen = subscribeOptions.maxSeen ?? 10_000;
/** @type {Set<string>} */
const seen = new Set();
@@ -327,6 +332,12 @@ export function createRelayTransport(urls, options = {}) {
if (!event?.id || seen.has(event.id)) {
return;
}
if (seen.size >= maxSeen) {
const oldest = seen.values().next().value;
if (oldest !== undefined) {
seen.delete(oldest);
}
}
seen.add(event.id);
handlers.onEvent?.(event);
},
+188 -14
View File
@@ -39,6 +39,30 @@ import {
import { snapshotFingerprint } from "@bitgate/core";
import { Emitter } from "./emitter.js";
/** Governance document scopes, used to build a bounded `#d` filter. */
const GOVERNANCE_SCOPES = [
"roles",
"policy",
"user-allow",
"user-deny",
"event-deny",
"address-deny",
"trust-seed",
"community-sources",
];
/** Legacy administrative list identifiers, read for migration compatibility. */
const LEGACY_IDENTIFIERS = [
"bitvid:admin:whitelist",
"bitvid:admin:blacklist",
"bitvid:admin:event-blacklist",
"bitvid:admin:editors",
"bitvid:admin:community-sources",
];
/** Maximum administrative events accepted from one query. */
const DEFAULT_ADMIN_LIMIT = 500;
import { createMemoryStorage, createNullSigner, storageKey } from "./interfaces.js";
import {
GovernanceAdminStore,
@@ -116,7 +140,12 @@ export class GovernanceRuntime extends Emitter {
* @param {number} [options.chunkSize]
* @param {number} [options.muteWindowSeconds]
* @param {import('@bitgate/nostr').SignatureVerifier} [options.verifySignature]
* @param {boolean} [options.trustUnsignedEvents] - Accept administrative events
* without a verifier. Development and tests only.
* @param {string} [options.root] - Root administrator pubkey, from deployment config
* @param {number} [options.maxCachedDecisions] - Ceiling on cached decisions
* @param {number} [options.maxReportTargets] - Ceiling on targets held by the report store
* @param {number} [options.maxMuteLists] - Ceiling on mute lists held
*/
constructor({
applicationId,
@@ -129,7 +158,11 @@ export class GovernanceRuntime extends Emitter {
chunkSize = DEFAULT_CHUNK_SIZE,
muteWindowSeconds = 0,
verifySignature,
trustUnsignedEvents,
root,
maxCachedDecisions = 5_000,
maxReportTargets = 20_000,
maxMuteLists = 5_000,
}) {
super();
@@ -155,6 +188,10 @@ export class GovernanceRuntime extends Emitter {
// rather than pretending state was verified.
this.verifySignature = verifySignature;
// Opt-in escape hatch for local development and tests, where events are
// constructed in-process and there is no relay to lie.
this.trustUnsignedEvents = trustUnsignedEvents === true;
// The deployment's own relays, used as a fallback for authors who have
// published no NIP-65 list.
/** @type {string[]} */
@@ -162,8 +199,12 @@ export class GovernanceRuntime extends Emitter {
this.admin = new GovernanceAdminStore();
this.trust = new TrustGraphStore();
this.reports = new ReportStore();
this.mutes = new TrustedMuteStore({ windowSeconds: muteWindowSeconds, now: this.now });
this.reports = new ReportStore({ maxTargets: maxReportTargets });
this.mutes = new TrustedMuteStore({
windowSeconds: muteWindowSeconds,
now: this.now,
maxLists: maxMuteLists,
});
/** @type {PolicyStore} */
this.policies = new PolicyStore(policy ?? NEUTRAL_POLICY);
this.overrides = new OverrideStore({ now: this.now });
@@ -172,8 +213,15 @@ export class GovernanceRuntime extends Emitter {
// Seeding it here keeps the storage key stable from construction; without
// it, hydration could not find its own cache, because the key is derived
// from the root fingerprint the cache would have to supply.
if (root) {
this.admin.setRoles({ root });
// Kept separate from admin.authority.root on purpose. The authority state
// can be rewritten by ingested documents; this is the deployment's own
// configuration and is what every authorship check compares against, so a
// bad ingest cannot move the goalposts for the next one.
/** @type {string} */
this.configuredRoot = root ? normalizePubkey(root) : "";
if (this.configuredRoot) {
this.admin.setRoles({ root: this.configuredRoot });
}
/** @type {string} */
@@ -234,6 +282,10 @@ export class GovernanceRuntime extends Emitter {
// for every card is the cost the extraction is supposed to remove.
/** @type {Map<string, GovernanceDecision>} */
this.decisionCache = new Map();
// Without a ceiling this grows for the lifetime of the page: an
// infinite-scroll feed evaluates new targets forever and never revisits
// most of them. Insertion order gives a cheap approximation of LRU.
this.maxCachedDecisions = maxCachedDecisions;
// The snapshot and its fingerprint are rebuilt only when a store changes.
// Deriving them per target walks every report and mute list, which turns a
@@ -252,6 +304,8 @@ export class GovernanceRuntime extends Emitter {
rejectedSignatures: 0,
hydratedFromCache: false,
privateMutesApplied: 0,
rejectedUnauthorized: 0,
rejectedUnverified: 0,
};
}
@@ -331,18 +385,32 @@ export class GovernanceRuntime extends Emitter {
if (event.kind === CANONICAL_KIND) {
const roles = decodeRoles(event);
if (roles) {
// The roster is the root of the entire trust model: it decides who may
// deny whom, and who is protected. Accepting one from an arbitrary
// author lets anybody declare themselves root. Only the deployment's
// configured root may publish it.
if (!this.#isRootAuthored(event)) {
this.diagnostics.rejectedUnauthorized += 1;
this.emit("rejected", { reason: "roles-not-root", pubkey: event.pubkey });
return false;
}
if (!this.#adminIngestAllowed()) {
return false;
}
this.admin.setRoles(roles);
return true;
}
const policyDocument = decodePolicy(event);
if (policyDocument) {
this.applyRootPolicy(policyDocument, event.pubkey);
return true;
return this.applyRootPolicy(policyDocument, event.pubkey);
}
const contribution = decodeContribution(event);
if (contribution) {
if (!this.#adminIngestAllowed()) {
return false;
}
this.admin.upsertContribution(contribution);
return true;
}
@@ -351,6 +419,9 @@ export class GovernanceRuntime extends Emitter {
if (event.kind === LEGACY_KIND) {
const contribution = decodeLegacyList(event);
if (contribution) {
if (!this.#adminIngestAllowed()) {
return false;
}
this.admin.upsertContribution(contribution);
return true;
}
@@ -360,6 +431,47 @@ export class GovernanceRuntime extends Emitter {
return false;
}
/**
* Whether an event was authored by the deployment's configured root.
*
* Falls back to the current authority root only when no root was configured,
* which is a development convenience — a deployment with no configured root
* has no way to distinguish its administrator from anyone else.
*
* @param {NostrEvent} event
* @returns {boolean}
*/
#isRootAuthored(event) {
const root = this.configuredRoot || this.admin.authority.root;
if (!root) {
return false;
}
return normalizePubkey(event?.pubkey ?? "") === root;
}
/**
* Whether administrative state may be ingested at all.
*
* Without signature verification, `event.pubkey` is an unauthenticated claim:
* a hostile relay can put the root's key on an event the root never wrote, so
* an authorship check alone proves nothing. Administrative documents
* therefore fail closed unless a verifier is configured, or the application
* explicitly opts in for local and test use.
*
* Trust signals — reports, mutes — are not gated here: they are bounded by
* the viewer's own trust graph rather than by authority.
*
* @returns {boolean}
*/
#adminIngestAllowed() {
if (this.verifySignature || this.trustUnsignedEvents) {
return true;
}
this.diagnostics.rejectedUnverified += 1;
this.emit("rejected", { reason: "unverified-administrative-event" });
return false;
}
/**
* Apply a root-published policy document.
*
@@ -372,7 +484,7 @@ export class GovernanceRuntime extends Emitter {
* @returns {boolean} Whether the policy was accepted
*/
applyRootPolicy(document, publisher) {
const root = this.admin.authority.root;
const root = this.configuredRoot || this.admin.authority.root;
if (!root || normalizePubkey(publisher) !== root) {
this.emit("policy-rejected", { reason: "not-root", publisher });
return false;
@@ -435,8 +547,19 @@ export class GovernanceRuntime extends Emitter {
const admin = /** @type {any} */ (await this.storage.read(this.storageKeyFor("admin")));
if (admin?.schemaVersion === this.schemaVersion) {
// Cache is a performance tier, never an authority tier. Storage is
// writable by anything running on this origin, so a cached roster naming
// a different root would turn any same-origin XSS into a persistent
// moderation takeover. A mismatch means the cache is not ours.
const cachedRoot = normalizePubkey(admin.authority?.root ?? "");
if (this.configuredRoot && cachedRoot && cachedRoot !== this.configuredRoot) {
this.emit("rejected", { reason: "cached-root-mismatch", pubkey: cachedRoot });
this.diagnostics.hydratedFromCache = false;
return false;
}
if (admin.authority) {
this.admin.setRoles(admin.authority);
this.admin.setRoles({ ...admin.authority, root: this.configuredRoot || cachedRoot });
}
if (Array.isArray(admin.contributions)) {
this.admin.setContributions(admin.contributions);
@@ -463,6 +586,26 @@ export class GovernanceRuntime extends Emitter {
return restored;
}
/**
* Ingest an event that arrived on a live subscription.
*
* Subscriptions previously called ingestEvent directly, so state fetched at
* load was verified and the identical state arriving live was not — an
* attacker only had to wait. Verification is async, so this wraps it.
*
* @param {NostrEvent} event
* @returns {Promise<boolean>}
*/
async #ingestFromSubscription(event) {
if (this.verifySignature) {
const [verified] = await this.#verified([event]);
if (!verified) {
return false;
}
}
return this.ingestEvent(event);
}
/**
* Verify a batch of events when a verifier is configured.
* @param {NostrEvent[]} events
@@ -498,15 +641,35 @@ export class GovernanceRuntime extends Emitter {
await this.hydrate();
}
// Narrowed to the exact documents governance uses. An unfiltered query for
// kinds 30078 and 30000 asks a relay for every application-data and
// people-list event in existence: a self-inflicted denial of service on
// page load, and a privacy problem since it pulls unrelated app data.
const identifiers = GOVERNANCE_SCOPES.map(
(scope) => `${this.namespace}:governance:${scope}:v1`,
);
/** @type {any} */
const filter = { kinds: [CANONICAL_KIND, LEGACY_KIND] };
const canonicalFilter = {
kinds: [CANONICAL_KIND],
"#d": identifiers,
limit: DEFAULT_ADMIN_LIMIT,
};
/** @type {any} */
const legacyFilter = {
kinds: [LEGACY_KIND],
"#d": LEGACY_IDENTIFIERS,
limit: DEFAULT_ADMIN_LIMIT,
};
if (authors?.length) {
filter.authors = authors;
canonicalFilter.authors = authors;
legacyFilter.authors = authors;
}
let events;
try {
events = await this.transport.list([filter]);
events = await this.transport.list([canonicalFilter, legacyFilter]);
} catch (error) {
this.stale = true;
this.emit("stale", { reason: "relay-unreachable", error: String(error) });
@@ -703,7 +866,7 @@ export class GovernanceRuntime extends Emitter {
if (!authorChunk.length) continue;
opened.push(
this.transport.subscribe([{ kinds: [MUTE_LIST_KIND], authors: authorChunk }], {
onEvent: (event) => this.ingestEvent(event),
onEvent: (event) => void this.#ingestFromSubscription(event),
}),
);
}
@@ -791,7 +954,7 @@ export class GovernanceRuntime extends Emitter {
if (!ids.length) continue;
opened.push(
this.transport.subscribe([{ kinds: [REPORT_KIND], "#e": ids }], {
onEvent: (event) => this.ingestEvent(event),
onEvent: (event) => void this.#ingestFromSubscription(event),
}),
);
}
@@ -800,7 +963,7 @@ export class GovernanceRuntime extends Emitter {
if (!keys.length) continue;
opened.push(
this.transport.subscribe([{ kinds: [REPORT_KIND], "#p": keys }], {
onEvent: (event) => this.ingestEvent(event),
onEvent: (event) => void this.#ingestFromSubscription(event),
}),
);
}
@@ -966,6 +1129,17 @@ export class GovernanceRuntime extends Emitter {
// Frozen because the cache hands out the same object to every caller; a
// consumer mutating a decision would otherwise corrupt later reads.
this.decisionCache.set(cacheKey, deepFreeze(decision));
if (this.decisionCache.size > this.maxCachedDecisions) {
// Maps iterate in insertion order, so the first key is the least
// recently added — good enough eviction for a cache whose miss cost is
// one pure evaluation.
const oldest = this.decisionCache.keys().next().value;
if (oldest !== undefined) {
this.decisionCache.delete(oldest);
}
}
return decision;
}
+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));
}
+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([
+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);
+351
View File
@@ -0,0 +1,351 @@
// Regressions for findings from the security audit.
//
// Each case reproduces the original exploit. They exist so a refactor cannot
// quietly reopen a hole that was already closed once.
import { describe, expect, it, vi } from "vitest";
import { createPolicyDefinition, getPolicyPreset, resolveThresholds } from "@bitgate/core";
import { encodeContribution, encodeRoles } from "@bitgate/nostr";
import { createBitGate, createMemoryTransport } from "@bitgate/runtime";
const ROOT = "a1".repeat(32);
const ATTACKER = "ee".repeat(32);
const VICTIM = "d4".repeat(32);
const NOW = 1_750_000_000;
const POLICY = createPolicyDefinition({
id: "audit",
version: "1.0.0",
defaultProfile: "feed",
profiles: {
feed: {
name: "feed",
administrativeDeny: { visibility: "hide", interaction: "deny" },
reports: { spam: { restrict: 3 }, default: { restrict: 1 } },
mutes: {},
},
},
});
function makeRuntime(options = {}) {
return createBitGate({
applicationId: "audit",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
trustUnsignedEvents: true,
...options,
});
}
const signed = (pubkey, template, id = "01") => ({
id: id.repeat(32),
pubkey,
created_at: NOW,
...template,
});
describe("finding 1: roster takeover", () => {
it("refuses a roles document from anyone but the configured root", () => {
const runtime = makeRuntime();
const rejected = vi.fn();
runtime.on("rejected", rejected);
const accepted = runtime.ingestEvent(
signed(ATTACKER, encodeRoles({ actors: { [ATTACKER]: ["super_admin"] } }, "app")),
);
expect(accepted).toBe(false);
expect(runtime.admin.authority.root).toBe(ROOT);
expect(runtime.can(ATTACKER, "manage-roles")).toBe(false);
expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ reason: "roles-not-root" }));
});
it("still accepts the configured root's own roster", () => {
const runtime = makeRuntime();
const moderator = "b2".repeat(32);
expect(
runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: { [moderator]: ["moderator"] } }, "app"))),
).toBe(true);
expect(runtime.can(moderator, "contribute-user-deny")).toBe(true);
});
it("leaves an attacker unable to deny anyone", () => {
const runtime = makeRuntime();
runtime.ingestEvent(
signed(ATTACKER, encodeRoles({ actors: { [ATTACKER]: ["super_admin"] } }, "app"), "01"),
);
runtime.ingestEvent(
signed(
ATTACKER,
encodeContribution(
{ actor: ATTACKER, kind: "user-deny", targets: [{ type: "user", pubkey: VICTIM }] },
"app",
),
"02",
),
);
expect(runtime.admin.state.userDeny.has(`user:${VICTIM}`)).toBe(false);
expect(runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).visibility.effect).toBe(
"allow",
);
});
it("compares against the configured root, not the current authority", () => {
// Defence in depth: even if authority state were somehow rewritten, the
// configured root is what authorship is checked against.
const runtime = makeRuntime();
runtime.admin.setRoles({ root: ATTACKER, actors: { [ATTACKER]: ["super_admin"] } });
expect(
runtime.ingestEvent(signed(ATTACKER, encodeRoles({ actors: {} }, "app"))),
).toBe(false);
});
it("refuses every roster when no root is configured", () => {
const runtime = makeRuntime({ root: undefined });
expect(runtime.ingestEvent(signed(ATTACKER, encodeRoles({ actors: {} }, "app")))).toBe(false);
});
});
describe("finding 1b: administrative state fails closed without verification", () => {
it("refuses administrative documents when no verifier is configured", () => {
const runtime = createBitGate({
applicationId: "audit",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
});
const rejected = vi.fn();
runtime.on("rejected", rejected);
// Authored by the real root — but pubkey is an unauthenticated claim
// without a signature check, so a relay could have forged it.
expect(runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app")))).toBe(false);
expect(rejected).toHaveBeenCalledWith(
expect.objectContaining({ reason: "unverified-administrative-event" }),
);
});
it("accepts them once a verifier is configured", () => {
const runtime = createBitGate({
applicationId: "audit",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
verifySignature: () => true,
});
expect(runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app")))).toBe(true);
});
it("reports the posture in diagnostics", () => {
const runtime = createBitGate({
applicationId: "audit",
namespace: "app",
root: ROOT,
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
});
runtime.ingestEvent(signed(ROOT, encodeRoles({ actors: {} }, "app")));
const description = runtime.describe();
expect(description.signatureVerification).toBe("disabled");
expect(description.rejectedUnverified).toBeGreaterThan(0);
});
});
describe("finding 2: threshold evasion through inherited keys", () => {
const table = { spam: { restrict: 3 }, default: { restrict: 1 } };
it("falls back to the default for prototype-chain category names", () => {
// "constructor" previously resolved to the Object constructor: truthy, with
// every gate undefined, which exempted the category from all thresholds.
for (const category of ["constructor", "__proto__", "valueof", "hasownproperty"]) {
expect(resolveThresholds(table, category), category).toEqual({ restrict: 1 });
}
});
it("still resolves real categories and unknown ones", () => {
expect(resolveThresholds(table, "spam")).toEqual({ restrict: 3 });
expect(resolveThresholds(table, "unknown")).toEqual({ restrict: 1 });
});
it("returns an empty object when there is no default", () => {
expect(resolveThresholds({ spam: { restrict: 3 } }, "constructor")).toEqual({});
});
it("applies the default threshold to a crafted report category end to end", () => {
const runtime = makeRuntime();
const trusted = "01".repeat(32);
runtime.trust.setContacts([trusted]);
runtime.reports.ingest(
{ reporter: trusted, category: "constructor", createdAt: NOW },
`user:${VICTIM}`,
);
// One trusted report, default threshold restrict: 1 — it must bite.
expect(runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).visibility.effect).toBe(
"restrict",
);
});
it("counts a crafted category in evidence rather than dropping it", () => {
const runtime = makeRuntime();
const trusted = "01".repeat(32);
runtime.trust.setContacts([trusted]);
runtime.reports.ingest(
{ reporter: trusted, category: "__proto__", createdAt: NOW },
`user:${VICTIM}`,
);
const evidence = runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "feed" }).evidence;
expect(evidence?.trustedReportTotal).toBe(1);
expect(evidence?.trustedReportsByCategory.__proto__).toBe(1);
});
it("does not hand back a prototype member as a policy preset", () => {
for (const name of ["constructor", "__proto__", "toString"]) {
expect(getPolicyPreset(name), name).toBeNull();
}
expect(getPolicyPreset("social")).not.toBeNull();
});
it("rejects a prototype member as a profile name", () => {
const runtime = makeRuntime();
expect(() =>
runtime.evaluate({ type: "user", pubkey: VICTIM }, { profile: "constructor" }),
).toThrow(/Unknown policy profile/);
});
});
describe("finding 3: live subscriptions are verified", () => {
it("drops a subscription event the verifier rejects", () => {
const transport = createMemoryTransport();
const runtime = createBitGate({
applicationId: "audit",
namespace: "app",
root: ROOT,
transport,
policy: POLICY,
now: () => NOW,
verifySignature: () => false,
});
runtime.setActiveTargets([{ type: "user", pubkey: VICTIM }]);
runtime.subscribeToActiveTargetReports();
transport.deliver({
id: "aa".repeat(32),
pubkey: "01".repeat(32),
kind: 1984,
created_at: NOW,
tags: [["p", VICTIM, "spam"]],
content: "",
});
expect(runtime.reports.recordsFor(`user:${VICTIM}`)).toEqual([]);
});
});
describe("finding 4: the administrative query is bounded", () => {
it("filters by governance identifiers and a limit", async () => {
const captured = [];
const transport = {
...createMemoryTransport(),
relays: ["wss://r.example"],
async list(filters) {
captured.push(...filters);
return [];
},
};
const runtime = makeRuntime({ transport });
await runtime.loadAdministrativeState({ hydrateFirst: false, persistAfter: false });
// Never an unfiltered request for every kind:30078 on the relay.
expect(captured.length).toBeGreaterThan(0);
for (const filter of captured) {
expect(filter["#d"], JSON.stringify(filter)).toBeDefined();
expect(filter.limit).toBeGreaterThan(0);
}
expect(captured[0]["#d"]).toContain("app:governance:roles:v1");
});
});
describe("finding 5: cached state is not an authority", () => {
it("refuses a cache naming a different root", async () => {
const stored = new Map();
const storage = {
async read(key) {
return stored.has(key) ? stored.get(key) : null;
},
async write(key, value) {
stored.set(key, value);
},
async remove(key) {
stored.delete(key);
},
};
const runtime = makeRuntime({ storage });
// Simulate storage tampered with by same-origin script.
stored.set(runtime.storageKeyFor("admin"), {
schemaVersion: "v1",
authority: { root: ATTACKER, actors: { [ATTACKER]: ["super_admin"] } },
contributions: [],
});
expect(await runtime.hydrate()).toBe(false);
expect(runtime.admin.authority.root).toBe(ROOT);
expect(runtime.can(ATTACKER, "manage-roles")).toBe(false);
});
});
describe("finding 7: growth is bounded", () => {
it("evicts report targets past the ceiling", () => {
const runtime = makeRuntime({ maxReportTargets: 10 });
for (let index = 0; index < 50; index += 1) {
runtime.reports.ingest(
{ reporter: "01".repeat(32), category: "spam", createdAt: NOW },
`user:${index.toString(16).padStart(2, "0").repeat(32)}`,
);
}
expect(runtime.reports.reports.size).toBeLessThanOrEqual(11);
});
it("evicts cached decisions past the ceiling", () => {
const runtime = makeRuntime({ maxCachedDecisions: 5 });
for (let index = 1; index <= 30; index += 1) {
runtime.evaluate(
{ type: "user", pubkey: index.toString(16).padStart(2, "0").repeat(32) },
{ profile: "feed" },
);
}
expect(runtime.decisionCache.size).toBeLessThanOrEqual(6);
});
it("evicts mute lists past the ceiling", () => {
const runtime = makeRuntime({ maxMuteLists: 5 });
for (let index = 1; index <= 30; index += 1) {
runtime.mutes.replaceList({
owner: index.toString(16).padStart(2, "0").repeat(32),
updatedAt: NOW,
entries: [{ pubkey: VICTIM }],
hasEncryptedEntries: false,
});
}
expect(runtime.mutes.lists.size).toBeLessThanOrEqual(6);
});
});