Files
bitgate/packages/nostr/test/legacy.test.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

187 lines
5.7 KiB
JavaScript

import { describe, expect, it } from "vitest";
import { decodeContribution } from "../src/canonical.js";
import {
LEGACY_COMMUNITY_SOURCES_IDENTIFIER,
LEGACY_EDITORS_IDENTIFIER,
LEGACY_KIND,
decodeLegacyCommunitySources,
decodeLegacyEditors,
decodeLegacyList,
decodeMixedContributions,
isLegacyAdminEvent,
} from "../src/legacy.js";
const ROOT = "a1".repeat(32);
const CURATOR = "c3".repeat(32);
const CREATOR = "d4".repeat(32);
const EVENT_ID = "1b".repeat(32);
const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const NPUB_HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
/**
* @param {Partial<import('../src/replaceable.js').NostrEvent>} parts
* @returns {import('../src/replaceable.js').NostrEvent}
*/
const event = (parts) => ({
id: "00".repeat(32),
pubkey: ROOT,
kind: LEGACY_KIND,
created_at: 1000,
tags: [],
content: "",
...parts,
});
describe("isLegacyAdminEvent", () => {
it("recognizes the known legacy lists", () => {
for (const identifier of [
"bitvid:admin:whitelist",
"bitvid:admin:blacklist",
"bitvid:admin:event-blacklist",
LEGACY_EDITORS_IDENTIFIER,
LEGACY_COMMUNITY_SOURCES_IDENTIFIER,
]) {
expect(isLegacyAdminEvent(event({ tags: [["d", identifier]] }))).toBe(true);
}
});
it("rejects unrelated lists", () => {
expect(isLegacyAdminEvent(event({ tags: [["d", "bitvid:playlist"]] }))).toBe(false);
});
it("rejects the wrong kind", () => {
expect(isLegacyAdminEvent(event({ kind: 30078, tags: [["d", "bitvid:admin:blacklist"]] }))).toBe(
false,
);
});
});
describe("decodeLegacyList", () => {
it("maps the blacklist onto a user denial", () => {
const contribution = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] }),
);
expect(contribution).toMatchObject({
actor: ROOT,
kind: "user-deny",
targets: [{ type: "user", pubkey: CREATOR }],
});
});
it("maps the whitelist onto a user allowance", () => {
const contribution = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:whitelist"], ["p", CREATOR]] }),
);
expect(contribution?.kind).toBe("user-allow");
});
it("maps the event blacklist onto an event denial", () => {
const contribution = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:event-blacklist"], ["e", EVENT_ID]] }),
);
expect(contribution?.targets).toEqual([{ type: "event", id: EVENT_ID }]);
});
it("normalizes npub entries to hex", () => {
const contribution = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }),
);
expect(contribution?.targets).toEqual([{ type: "user", pubkey: NPUB_HEX }]);
});
it("treats hex and npub entries as the same target", () => {
const fromNpub = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB]] }),
);
const fromHex = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", NPUB_HEX]] }),
);
expect(fromNpub?.targets).toEqual(fromHex?.targets);
});
it("returns null for the editor roster", () => {
expect(decodeLegacyList(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER]] }))).toBeNull();
});
it("returns null for an unknown identifier", () => {
expect(decodeLegacyList(event({ tags: [["d", "bitvid:other"]] }))).toBeNull();
});
it("skips malformed entries", () => {
const contribution = decodeLegacyList(
event({ tags: [["d", "bitvid:admin:blacklist"], ["p", "junk"], ["p", CREATOR]] }),
);
expect(contribution?.targets).toHaveLength(1);
});
});
describe("decodeLegacyEditors", () => {
it("returns editors as plain pubkeys", () => {
expect(
decodeLegacyEditors(event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR]] })),
).toEqual([CREATOR]);
});
it("deduplicates", () => {
expect(
decodeLegacyEditors(
event({ tags: [["d", LEGACY_EDITORS_IDENTIFIER], ["p", CREATOR], ["p", CREATOR]] }),
),
).toHaveLength(1);
});
it("returns nothing for other lists", () => {
expect(decodeLegacyEditors(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual([]);
});
});
describe("decodeLegacyCommunitySources", () => {
it("decodes curator list references", () => {
const sources = decodeLegacyCommunitySources(
event({
tags: [
["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER],
["a", `30000:${CURATOR}:bitvid:admin:blacklist`],
],
}),
);
expect(sources).toEqual([
{ curator: CURATOR, identifier: "bitvid:admin:blacklist", kind: 30000 },
]);
});
it("skips malformed coordinates", () => {
const sources = decodeLegacyCommunitySources(
event({ tags: [["d", LEGACY_COMMUNITY_SOURCES_IDENTIFIER], ["a", "garbage"]] }),
);
expect(sources).toEqual([]);
});
it("returns nothing for other lists", () => {
expect(decodeLegacyCommunitySources(event({ tags: [["d", "bitvid:admin:blacklist"]] }))).toEqual(
[],
);
});
});
describe("decodeMixedContributions", () => {
it("reads legacy and canonical events side by side", () => {
const legacy = event({ tags: [["d", "bitvid:admin:blacklist"], ["p", CREATOR]] });
const canonical = event({
kind: 30078,
tags: [["d", "app:governance:user-deny:v1"], ["p", CREATOR]],
});
const contributions = decodeMixedContributions([legacy, canonical], decodeContribution);
expect(contributions).toHaveLength(2);
expect(contributions.every((entry) => entry.kind === "user-deny")).toBe(true);
});
it("ignores events neither codec recognizes", () => {
expect(decodeMixedContributions([event({ tags: [["d", "unknown"]] })], decodeContribution)).toEqual(
[],
);
});
});