mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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.
388 lines
11 KiB
JavaScript
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);
|
|
}
|