Files
bitgate/packages/runtime/src/commands.js
T
thePR0M3TH3AN 94ef6a72f5 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.
2026-08-09 21:52:44 -04:00

388 lines
11 KiB
JavaScript

// Public governance commands.
//
// Every command checks authority before signing. An unauthorized actor must not
// be able to produce an accepted mutation, so the capability check happens
// locally before the signer is ever invoked — a relay is not an access-control
// mechanism, and a client that skipped this check would publish an event that
// merely fails to take effect, which is far harder to diagnose.
import {
getTargetKey,
hasCapability,
isProtectedActor,
isValidTarget,
} from "@bitgate/core";
import { encodeContribution, encodeReport, encodeRoles } from "@bitgate/nostr";
/**
* Stable error codes. Applications match on these rather than on message text.
*/
export const ERROR_CODES = Object.freeze({
NO_SIGNER: "no-signer-configured",
NOT_AUTHORIZED: "not-authorized",
INVALID_TARGET: "invalid-target",
PROTECTED_TARGET: "protected-target",
PUBLISH_FAILED: "publish-failed",
INVALID_ARGUMENT: "invalid-argument",
});
/**
* The signed event a command published.
*
* Described structurally rather than imported so that this package's public
* types stay nameable without reaching into another package's internal modules.
*
* @typedef {Object} SignedEvent
* @property {string} id
* @property {string} pubkey
* @property {number} kind
* @property {number} created_at
* @property {string[][]} tags
* @property {string} content
* @property {string} [sig]
*/
/**
* @typedef {Object} CommandResult
* @property {boolean} ok
* @property {string} [code] - One of ERROR_CODES when ok is false
* @property {string} [message]
* @property {SignedEvent} [event]
* @property {string[]} [accepted] - Relays that accepted
* @property {Array<{ relay: string, error: string }>} [failed]
*/
/**
* @param {string} code
* @param {string} message
* @returns {CommandResult}
*/
function failure(code, message) {
return { ok: false, code, message };
}
/** @type {Record<string, import('@bitgate/core').GovernanceCapability>} */
const KIND_CAPABILITY = {
"user-allow": "contribute-user-allow",
"user-deny": "contribute-user-deny",
"event-deny": "contribute-event-deny",
"address-deny": "contribute-address-deny",
"trust-seed": "contribute-trust-seed",
};
export class GovernanceCommands {
/**
* @param {import('./runtime.js').GovernanceRuntime} runtime
*/
constructor(runtime) {
this.runtime = runtime;
}
/**
* Sign and publish an event template.
*
* A partial relay acceptance still succeeds: the first acceptance means the
* event exists on the network. The remaining relay results stay on the result
* so diagnostics can surface them.
*
* @param {{ kind: number, content: string, tags: string[][] }} template
* @returns {Promise<CommandResult>}
*/
async #publish(template) {
let signed;
try {
signed = await this.runtime.signer.signEvent({
...template,
created_at: this.runtime.now(),
});
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
try {
const result = await this.runtime.transport.publish(signed);
if (!result?.ok) {
return {
...failure(ERROR_CODES.PUBLISH_FAILED, "no relay accepted the event"),
event: signed,
accepted: result?.accepted ?? [],
failed: result?.failed ?? [],
};
}
return {
ok: true,
event: signed,
accepted: result.accepted ?? [],
failed: result.failed ?? [],
};
} catch (error) {
return { ...failure(ERROR_CODES.PUBLISH_FAILED, String(error)), event: signed };
}
}
/**
* Resolve the acting pubkey from the signer.
* @returns {Promise<string>}
*/
async #actor() {
return this.runtime.signer.getPublicKey();
}
/**
* Publish a contribution list.
*
* @param {import('@bitgate/core').Contribution["kind"]} kind
* @param {import('@bitgate/core').GovernanceTarget[]} targets
* @param {Object} [options]
* @param {string} [options.source] - Community list marker
* @returns {Promise<CommandResult>}
*/
async contribute(kind, targets, { source } = {}) {
const capability = KIND_CAPABILITY[kind];
if (!capability) {
return failure(ERROR_CODES.INVALID_ARGUMENT, `unknown contribution kind: ${kind}`);
}
if (!Array.isArray(targets) || targets.length === 0) {
return failure(ERROR_CODES.INVALID_ARGUMENT, "targets must be a non-empty array");
}
for (const target of targets) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
}
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
const authority = this.runtime.admin.authority;
if (!hasCapability(actor, capability, authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, `actor lacks ${capability}`);
}
// Protected actors are rejected at the command boundary as well as during
// reduction, so an operator sees the refusal instead of publishing a list
// entry that silently never takes effect.
if (kind === "user-deny") {
for (const target of targets) {
if (target.type === "user" && isProtectedActor(target.pubkey, authority)) {
return failure(ERROR_CODES.PROTECTED_TARGET, target.pubkey);
}
}
}
const template = encodeContribution(
{ actor, kind, targets, ...(source ? { source } : {}) },
this.runtime.namespace,
);
return this.#publish(template);
}
/**
* Deny a user.
* @param {string} pubkey
* @param {Object} [options]
* @returns {Promise<CommandResult>}
*/
async denyUser(pubkey, options) {
return this.contribute("user-deny", [{ type: "user", pubkey }], options);
}
/**
* Allow a user.
* @param {string} pubkey
* @returns {Promise<CommandResult>}
*/
async allowUser(pubkey) {
return this.contribute("user-allow", [{ type: "user", pubkey }]);
}
/**
* Deny an exact event.
* @param {string} id
* @returns {Promise<CommandResult>}
*/
async denyEvent(id) {
return this.contribute("event-deny", [{ type: "event", id }]);
}
/**
* Deny an address coordinate.
* @param {string} kind
* @param {string} pubkey
* @param {string} identifier
* @returns {Promise<CommandResult>}
*/
async denyAddress(kind, pubkey, identifier) {
return this.contribute("address-deny", [{ type: "address", kind, pubkey, identifier }]);
}
/**
* Add trust seeds.
* @param {string[]} pubkeys
* @returns {Promise<CommandResult>}
*/
async addTrustSeeds(pubkeys) {
return this.contribute(
"trust-seed",
(pubkeys ?? []).map((pubkey) => ({ type: "user", pubkey })),
);
}
/**
* Publish the role roster. Requires `manage-roles`.
* @param {Object} roster
* @param {Record<string, string[]>} [roster.actors]
* @param {Record<string, string[]>} [roster.capabilities]
* @param {string[]} [roster.protectedActors]
* @returns {Promise<CommandResult>}
*/
async setRoles(roster) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-roles", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-roles");
}
return this.#publish(encodeRoles(roster ?? {}, this.runtime.namespace));
}
/**
* Publish a policy document. Requires `manage-policy`.
* @param {import('@bitgate/core').PolicyDefinition} policy
* @returns {Promise<CommandResult>}
*/
async setPolicy(policy) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-policy", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-policy");
}
if (!policy || typeof policy !== "object") {
return failure(ERROR_CODES.INVALID_ARGUMENT, "policy must be an object");
}
return this.#publish({
kind: 30078,
content: JSON.stringify(policy),
tags: [
["d", `${this.runtime.namespace}:governance:policy:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "policy"],
],
});
}
/**
* Register a community source. Requires `manage-community-sources`.
* @param {Array<{ curator: string, identifier: string, kind: number }>} sources
* @returns {Promise<CommandResult>}
*/
async setCommunitySources(sources) {
let actor;
try {
actor = await this.#actor();
} catch (error) {
return failure(ERROR_CODES.NO_SIGNER, String(error));
}
if (!hasCapability(actor, "manage-community-sources", this.runtime.admin.authority)) {
return failure(ERROR_CODES.NOT_AUTHORIZED, "actor lacks manage-community-sources");
}
const tags = [
["d", `${this.runtime.namespace}:governance:community-sources:v1`],
["v", "1"],
["client", "bitgate"],
["scope", "community-sources"],
];
for (const source of sources ?? []) {
tags.push(["a", `${source.kind}:${source.curator}:${source.identifier}`]);
}
return this.#publish({ kind: 30078, content: "", tags });
}
/**
* Publish a NIP-56 report.
*
* Reporting needs no capability: any account may report, and whether the
* report counts is decided by the viewer's trust graph at evaluation time.
*
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} category
* @param {string} [content]
* @returns {Promise<CommandResult>}
*/
async report(target, category, content = "") {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
let template;
try {
template = encodeReport(target, category, content);
} catch (error) {
return failure(ERROR_CODES.INVALID_ARGUMENT, String(error));
}
return this.#publish(template);
}
/**
* Set a viewer override. Viewer-local, so no capability is required and
* nothing is published.
*
* @param {import('@bitgate/core').GovernanceTarget} target
* @param {string} visibility
* @param {Object} [options]
* @param {string} [options.reason]
* @param {number} [options.expiresAt]
* @returns {CommandResult}
*/
setOverride(target, visibility, { reason, expiresAt } = {}) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
this.runtime.overrides.set(getTargetKey(target), { visibility, reason, expiresAt });
return { ok: true };
}
/**
* Clear a viewer override.
* @param {import('@bitgate/core').GovernanceTarget} target
* @returns {CommandResult}
*/
clearOverride(target) {
if (!isValidTarget(target)) {
return failure(ERROR_CODES.INVALID_TARGET, JSON.stringify(target));
}
this.runtime.overrides.remove(getTargetKey(target));
return { ok: true };
}
}
/**
* @param {import('./runtime.js').GovernanceRuntime} runtime
* @returns {GovernanceCommands}
*/
export function createCommands(runtime) {
return new GovernanceCommands(runtime);
}