mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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:
1 parent
7a45f1cf3e
commit
94ef6a72f5
21 files changed
+788
-54
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ describe("contribution round-trip", () => {
|
||||
transport: createMemoryTransport(),
|
||||
policy: POLICY,
|
||||
now: () => NOW,
|
||||
root: ROOT,
|
||||
trustUnsignedEvents: true,
|
||||
});
|
||||
fresh.admin.setRoles({
|
||||
root: ROOT,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user