mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
Closes the gap the audit left open: administrative state fails closed without a verifier, and BitGate shipped none. @bitgate/verify is a separate package so core, nostr, and runtime keep no crypto dependency and can be audited without one. The widget bundles it, so the drop-in path is verified with no extra step; verify="off" disables it, sets data-unverified on the element, and emits bitgate:unverified — meant for local development, and meant to be visible. Verification recomputes the event id before checking the signature. Checking only the signature would let a forged event pass by reusing a genuine signature over an unrelated id, which is the failure mode that matters here. createVerifier also rejects events dated far in the future. A far-future created_at wins replaceable-event selection indefinitely and pins stale state in place; the signature is perfectly valid, so only a clock check catches it. Found while testing: #adminIngestAllowed checked that a verifier *exists*, not that this event was verified — so the public ingestEvent path, which "bring your own relays" integrations use, bypassed verification entirely. Verification now runs inside ingestEvent, with an already-verified flag for the loaders. An async verifier cannot be awaited there and is treated as unverified; ingestVerified() awaits properly. Conformance validated against 120 live events from a public relay across kinds 0, 1, 3 and 10002 — 59 with non-ASCII content and 71 containing quotes, newlines or tabs, which is where a serialization mismatch would surface. All 120 verified, zero id mismatches. Bundle grows 72K -> 120K. 855 tests; audit clean.
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
// @vitest-environment happy-dom
|
|
//
|
|
// The drop-in path: configuration in markup, descendants finding the runtime
|
|
// themselves, and targets read from attributes.
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { defineBitGateElements } from "../src/index.js";
|
|
import { targetFromAttributes } from "../src/base.js";
|
|
|
|
const ROOT = "a1".repeat(32);
|
|
const CREATOR = "d4".repeat(32);
|
|
const EVENT_ID = "1b".repeat(32);
|
|
|
|
defineBitGateElements();
|
|
|
|
/** Sockets opened during a test, so nothing tries to reach a real relay. */
|
|
let sockets = [];
|
|
|
|
class FakeSocket {
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.sent = [];
|
|
this.onopen = null;
|
|
this.onmessage = null;
|
|
this.onclose = null;
|
|
this.onerror = null;
|
|
sockets.push(this);
|
|
// Open on a microtask so listeners are attached first.
|
|
queueMicrotask(() => this.onopen?.());
|
|
}
|
|
send(payload) {
|
|
const frame = JSON.parse(payload);
|
|
this.sent.push(frame);
|
|
// Answer every REQ immediately so loadAdministrativeState resolves.
|
|
if (frame[0] === "REQ") {
|
|
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify(["EOSE", frame[1]]) }));
|
|
}
|
|
}
|
|
close() {}
|
|
}
|
|
|
|
/**
|
|
* Mount a provider and wait for it to finish loading.
|
|
* @param {Record<string, string|null>} [attributes]
|
|
* @param {string} [children]
|
|
* @returns {Promise<any>}
|
|
*/
|
|
async function mountProvider(attributes = {}, children = "") {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
for (const [name, value] of Object.entries({
|
|
relays: "wss://relay.example",
|
|
root: ROOT,
|
|
policy: "social",
|
|
...attributes,
|
|
})) {
|
|
if (value !== null) {
|
|
provider.setAttribute(name, value);
|
|
}
|
|
}
|
|
provider.innerHTML = children;
|
|
|
|
const loaded = new Promise((resolve) => {
|
|
provider.addEventListener("bitgate:loaded", resolve, { once: true });
|
|
provider.addEventListener("bitgate:error", resolve, { once: true });
|
|
});
|
|
|
|
document.body.append(provider);
|
|
await loaded;
|
|
return provider;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
sockets = [];
|
|
globalThis.WebSocket = /** @type {any} */ (FakeSocket);
|
|
globalThis.localStorage?.clear?.();
|
|
});
|
|
|
|
describe("targetFromAttributes", () => {
|
|
const parse = (attributes) => {
|
|
const element = document.createElement("div");
|
|
for (const [name, value] of Object.entries(attributes)) {
|
|
element.setAttribute(name, value);
|
|
}
|
|
return targetFromAttributes(element);
|
|
};
|
|
|
|
it("reads a user target", () => {
|
|
expect(parse({ "target-user": CREATOR })).toEqual({ type: "user", pubkey: CREATOR });
|
|
});
|
|
|
|
it("reads an event target with author and kind", () => {
|
|
expect(parse({ "target-event": EVENT_ID, "target-author": CREATOR, "target-kind": "1" })).toEqual({
|
|
type: "event",
|
|
id: EVENT_ID,
|
|
author: CREATOR,
|
|
kind: 1,
|
|
});
|
|
});
|
|
|
|
it("reads an address coordinate", () => {
|
|
expect(parse({ "target-address": `30078:${CREATOR}:sku-001` })).toEqual({
|
|
type: "address",
|
|
kind: "30078",
|
|
pubkey: CREATOR,
|
|
identifier: "sku-001",
|
|
});
|
|
});
|
|
|
|
it("keeps colons inside the d-tag", () => {
|
|
const target = parse({ "target-address": `30078:${CREATOR}:shop:sku:1` });
|
|
expect(target?.type === "address" && target.identifier).toBe("shop:sku:1");
|
|
});
|
|
|
|
it("returns null when no target attribute is present", () => {
|
|
expect(parse({})).toBeNull();
|
|
});
|
|
|
|
it("returns null for a malformed coordinate", () => {
|
|
expect(parse({ "target-address": "garbage" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("<bitgate-provider>", () => {
|
|
it("builds a runtime from attributes alone", async () => {
|
|
const provider = await mountProvider();
|
|
expect(provider.runtime).toBeTruthy();
|
|
expect(provider.commands).toBeTruthy();
|
|
expect(provider.runtime.admin.authority.root).toBe(ROOT);
|
|
});
|
|
|
|
it("uses the named policy preset", async () => {
|
|
const provider = await mountProvider({ policy: "commerce" });
|
|
expect(provider.runtime.policies.policy.id).toBe("bitgate-commerce");
|
|
});
|
|
|
|
it("defaults to administrative-only governance", async () => {
|
|
const provider = await mountProvider({ policy: null });
|
|
expect(provider.runtime.policies.policy.id).toBe("bitgate-admin-only");
|
|
});
|
|
|
|
it("opens a socket per relay", async () => {
|
|
await mountProvider({ relays: "wss://one.example, wss://two.example" });
|
|
expect(sockets.map((socket) => socket.url)).toEqual(["wss://one.example", "wss://two.example"]);
|
|
});
|
|
|
|
it("sets the viewer when given one", async () => {
|
|
const provider = await mountProvider({ viewer: CREATOR });
|
|
expect(provider.runtime.viewerPubkey).toBe(CREATOR);
|
|
});
|
|
|
|
it("reports an unknown policy without throwing", async () => {
|
|
const provider = await mountProvider({ policy: "invented" });
|
|
expect(provider.runtime).toBeNull();
|
|
expect(provider.getAttribute("data-error")).toMatch(/Unknown policy preset/);
|
|
});
|
|
|
|
it("reports a missing relays attribute", async () => {
|
|
const provider = await mountProvider({ relays: null });
|
|
expect(provider.getAttribute("data-error")).toMatch(/relays attribute/);
|
|
});
|
|
|
|
it("announces readiness before administrative state finishes loading", async () => {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
provider.setAttribute("relays", "wss://relay.example");
|
|
provider.setAttribute("policy", "social");
|
|
|
|
const ready = vi.fn();
|
|
provider.addEventListener("bitgate:ready", ready);
|
|
document.body.append(provider);
|
|
|
|
await new Promise((resolve) => provider.addEventListener("bitgate:loaded", resolve, { once: true }));
|
|
expect(ready).toHaveBeenCalled();
|
|
});
|
|
|
|
it("tears the runtime down when removed", async () => {
|
|
const provider = await mountProvider();
|
|
const runtime = provider.runtime;
|
|
provider.remove();
|
|
|
|
expect(provider.runtime).toBeNull();
|
|
expect(runtime.destroyed).toBe(true);
|
|
});
|
|
|
|
it("accepts a signer and adopts its pubkey as the viewer", async () => {
|
|
const provider = await mountProvider();
|
|
const signer = {
|
|
async getPublicKey() {
|
|
return CREATOR;
|
|
},
|
|
async signEvent(template) {
|
|
return { ...template, id: "ff".repeat(32), pubkey: CREATOR, sig: "00".repeat(64) };
|
|
},
|
|
};
|
|
|
|
const pubkey = await provider.useSigner(signer);
|
|
expect(pubkey).toBe(CREATOR);
|
|
expect(provider.runtime.viewerPubkey).toBe(CREATOR);
|
|
expect(provider.runtime.signer).toBe(signer);
|
|
});
|
|
|
|
it("refuses a signer before it is ready", async () => {
|
|
const provider = /** @type {any} */ (document.createElement("bitgate-provider"));
|
|
await expect(provider.useSigner(/** @type {any} */ ({}))).rejects.toThrow(/not ready/);
|
|
});
|
|
});
|
|
|
|
describe("context discovery", () => {
|
|
it("gives a descendant the runtime without any JavaScript", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-veil profile="feed" target-user="${CREATOR}"><p>content</p></bitgate-veil>`,
|
|
);
|
|
const veil = /** @type {any} */ (provider.querySelector("bitgate-veil"));
|
|
expect(veil.runtime).toBe(provider.runtime);
|
|
});
|
|
|
|
it("hands commands to elements that publish", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-report target-user="${CREATOR}"></bitgate-report>`,
|
|
);
|
|
const report = /** @type {any} */ (provider.querySelector("bitgate-report"));
|
|
expect(report.commands).toBe(provider.commands);
|
|
});
|
|
|
|
it("renders a decision from markup alone", async () => {
|
|
const provider = await mountProvider(
|
|
{ policy: "admin-only" },
|
|
`<bitgate-veil target-user="${CREATOR}"><p>content</p></bitgate-veil>`,
|
|
);
|
|
|
|
provider.runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
|
|
provider.runtime.admin.upsertContribution({
|
|
actor: ROOT,
|
|
kind: "user-deny",
|
|
targets: [{ type: "user", pubkey: CREATOR }],
|
|
createdAt: 1,
|
|
});
|
|
|
|
const veil = /** @type {any} */ (provider.querySelector("bitgate-veil"));
|
|
expect(veil.shadowRoot.textContent).toContain("Content hidden");
|
|
});
|
|
|
|
it("lets a directly assigned runtime win over the provider's", async () => {
|
|
const provider = await mountProvider(
|
|
{},
|
|
`<bitgate-status target-user="${CREATOR}"></bitgate-status>`,
|
|
);
|
|
const status = /** @type {any} */ (provider.querySelector("bitgate-status"));
|
|
const other = { on: () => () => {}, evaluate: () => null };
|
|
|
|
status.runtime = /** @type {any} */ (other);
|
|
expect(status.runtime).toBe(other);
|
|
});
|
|
|
|
it("leaves an element outside any provider unconfigured", () => {
|
|
const veil = /** @type {any} */ (document.createElement("bitgate-veil"));
|
|
document.body.append(veil);
|
|
expect(veil.runtime).toBeNull();
|
|
});
|
|
|
|
it("adopts a provider that becomes ready later", async () => {
|
|
// An element parsed before its provider finishes starting must still end up
|
|
// configured, or markup order would silently decide whether it works.
|
|
const veil = /** @type {any} */ (document.createElement("bitgate-veil"));
|
|
veil.setAttribute("target-user", CREATOR);
|
|
document.body.append(veil);
|
|
expect(veil.runtime).toBeNull();
|
|
|
|
const provider = await mountProvider();
|
|
expect(veil.runtime).toBe(provider.runtime);
|
|
});
|
|
});
|
|
|
|
describe("signature verification posture", () => {
|
|
it("verifies by default", async () => {
|
|
const provider = await mountProvider();
|
|
expect(provider.runtime.describe().signatureVerification).toBe("enabled");
|
|
expect(provider.hasAttribute("data-unverified")).toBe(false);
|
|
});
|
|
|
|
it("rejects a forged administrative event by default", async () => {
|
|
const provider = await mountProvider();
|
|
// Structurally plausible but unsigned — exactly what a hostile relay sends.
|
|
const accepted = provider.runtime.ingestEvent({
|
|
id: "ff".repeat(32),
|
|
pubkey: ROOT,
|
|
kind: 30078,
|
|
created_at: 1_750_000_000,
|
|
tags: [["d", "bitgate:governance:roles:v1"]],
|
|
content: "",
|
|
sig: "00".repeat(64),
|
|
});
|
|
expect(accepted).toBe(false);
|
|
});
|
|
|
|
it("can be disabled explicitly, and says so", async () => {
|
|
const provider = await mountProvider({ verify: "off" });
|
|
expect(provider.runtime.describe().signatureVerification).toBe("disabled");
|
|
expect(provider.hasAttribute("data-unverified")).toBe(true);
|
|
});
|
|
});
|