diff --git a/backend/scripts/verify-evidence-bundle.mjs b/backend/scripts/verify-evidence-bundle.mjs new file mode 100644 index 00000000..778ccdd8 --- /dev/null +++ b/backend/scripts/verify-evidence-bundle.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +/** + * Standalone, offline verifier for a signed evidence bundle (#1019). + * + * Depends on nothing but the Node standard library. It never contacts the + * Bridge Watch API or database — every commitment, signature and Merkle proof + * is recomputed from the bundle document itself. + * + * node scripts/verify-evidence-bundle.mjs path/to/bundle.json + * curl -s $API/api/v1/evidence/bundles/ | node scripts/verify-evidence-bundle.mjs - + * + * Exit code 0 = valid, 1 = invalid, 2 = usage error. + * + * The crypto below is intentionally a verbatim re-implementation of + * backend/src/services/transparencyLog/{canonical,merkle,ed25519,evidenceBundle}.ts. + */ + +import { createHash, createPublicKey, verify as edVerify } from "node:crypto"; +import { readFileSync } from "node:fs"; + +// ── canonical JSON (RFC 8785 subset) ────────────────────────────────────── +function canonicalize(value) { + if (value === null) return "null"; + const t = typeof value; + if (t === "number") { + if (!Number.isFinite(value)) throw new Error("non-finite number"); + return JSON.stringify(value); + } + if (t === "boolean" || t === "string") return JSON.stringify(value); + if (t === "bigint") return value.toString(); + if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`; + if (t === "object") { + const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(",")}}`; + } + throw new Error(`unsupported type ${t}`); +} +const cbytes = (v) => Buffer.from(canonicalize(v), "utf8"); + +// ── hashing / merkle (RFC 6962) ────────────────────────────────────────── +const sha256 = (...parts) => { + const h = createHash("sha256"); + for (const p of parts) h.update(p); + return h.digest(); +}; +const hashLeaf = (d) => sha256(Buffer.from([0x00]), d); +const hashChildren = (l, r) => sha256(Buffer.from([0x01]), l, r); +const toBuf = (h) => (typeof h === "string" ? Buffer.from(h, "hex") : h); +const splitPoint = (n) => { + let k = 1; + while (k << 1 < n) k <<= 1; + return k; +}; +function merkleTreeHash(leaves) { + const n = leaves.length; + if (n === 0) return sha256(Buffer.alloc(0)); + if (n === 1) return hashLeaf(leaves[0]); + const k = splitPoint(n); + return hashChildren(merkleTreeHash(leaves.slice(0, k)), merkleTreeHash(leaves.slice(k))); +} +const bitLen = (x) => (x === 0 ? 0 : Math.floor(Math.log2(x)) + 1); +const onesCount = (x) => { + let c = 0; + while (x > 0) { c += x & 1; x = Math.floor(x / 2); } + return c; +}; +function decompose(index, size) { + const inner = bitLen(index ^ (size - 1)); + return [inner, onesCount(Math.floor(index / 2 ** inner))]; +} +function chainInner(seed, proof, index) { + let acc = seed; + for (let i = 0; i < proof.length; i++) { + acc = (Math.floor(index / 2 ** i) & 1) === 0 ? hashChildren(acc, proof[i]) : hashChildren(proof[i], acc); + } + return acc; +} +function chainBorderRight(seed, proof) { + let acc = seed; + for (const h of proof) acc = hashChildren(h, acc); + return acc; +} +function verifyInclusion(leafHash, index, treeSize, proof, root) { + try { + if (index >= treeSize) return false; + const path = proof.map(toBuf); + const [inner, border] = decompose(index, treeSize); + if (path.length !== inner + border) return false; + let res = chainInner(toBuf(leafHash), path.slice(0, inner), index); + res = chainBorderRight(res, path.slice(inner)); + return res.equals(toBuf(root)); + } catch { + return false; + } +} + +// ── ed25519 over raw 32-byte keys ──────────────────────────────────────── +const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +function verifyRaw(publicKeyHex, message, signatureHex) { + try { + const raw = Buffer.from(publicKeyHex, "hex"); + if (raw.length !== 32) return false; + const key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" }); + return edVerify(null, message, key, Buffer.from(signatureHex, "hex")); + } catch { + return false; + } +} + +// ── bundle verification ────────────────────────────────────────────────── +const BUNDLE_FORMAT_VERSION = "1.0"; +const sectionContentHash = (saltHex, value) => sha256(Buffer.from(saltHex, "hex"), cbytes(value)).toString("hex"); +const sectionLeafBytes = (c) => cbytes({ sectionId: c.sectionId, mediaType: c.mediaType, contentHash: c.contentHash }); +const computeInputsRoot = (commitments) => merkleTreeHash(commitments.map(sectionLeafBytes)).toString("hex"); +const outputHashOf = (value) => sha256(cbytes(value)).toString("hex"); +const computeEvidenceRoot = (core) => sha256(cbytes(core)).toString("hex"); +const logLeafHashHex = (entryData) => hashLeaf(cbytes(entryData)).toString("hex"); + +function verifyEvidenceBundle(doc, opts = {}) { + const checks = []; + const add = (name, ok, detail) => checks.push({ name, ok, detail }); + const core = doc.core; + + add("format", core?.bundleFormatVersion === BUNDLE_FORMAT_VERSION, `version=${core?.bundleFormatVersion}`); + + const commitmentIds = (core?.sectionCommitments ?? []).map((c) => c.sectionId); + const disclosedIds = (doc.disclosedSections ?? []).map((s) => s.sectionId); + const redactedIds = doc.redactedSectionIds ?? []; + const partitionOk = + [...disclosedIds, ...redactedIds].length === commitmentIds.length && + new Set([...disclosedIds, ...redactedIds]).size === commitmentIds.length && + [...disclosedIds, ...redactedIds].every((id) => commitmentIds.includes(id)); + add("section_partition", partitionOk, `${disclosedIds.length} disclosed + ${redactedIds.length} redacted / ${commitmentIds.length}`); + + for (const s of doc.disclosedSections ?? []) { + const commitment = core.sectionCommitments.find((c) => c.sectionId === s.sectionId); + if (!commitment) { add("section_hash", false, `${s.sectionId}: no commitment`); continue; } + const recomputed = sectionContentHash(s.saltHex, s.value); + add("section_hash", recomputed === commitment.contentHash, `${s.sectionId}: ${recomputed === commitment.contentHash ? "ok" : "MISMATCH"}`); + } + + const inputsRoot = computeInputsRoot(core.sectionCommitments ?? []); + add("inputs_root", inputsRoot === core.inputsRoot, `${inputsRoot === core.inputsRoot ? "ok" : `${inputsRoot} != ${core.inputsRoot}`}`); + + for (const o of doc.disclosedOutputs ?? []) { + const commitment = core.derivedOutputs.find((d) => d.outputId === o.outputId); + if (!commitment) { add("output_hash", false, `${o.outputId}: no commitment`); continue; } + add("output_hash", outputHashOf(o.value) === commitment.outputHash, `${o.outputId}: ${outputHashOf(o.value) === commitment.outputHash ? "ok" : "MISMATCH"}`); + } + + const evidenceRoot = computeEvidenceRoot(core); + const rootOk = evidenceRoot === doc.evidenceRoot; + add("evidence_root", rootOk, rootOk ? "ok" : `${evidenceRoot} != ${doc.evidenceRoot}`); + + const sigOk = rootOk && verifyRaw(core.signer.publicKeyHex, Buffer.from(doc.evidenceRoot, "hex"), doc.signature); + add("signature", sigOk, sigOk ? `valid ed25519 by ${core.signer.keyId}` : "INVALID"); + + const asOf = opts.asOf ?? core.createdAt; + const fromOk = new Date(asOf) >= new Date(core.signer.validFrom); + const untilOk = !core.signer.validUntil || new Date(asOf) <= new Date(core.signer.validUntil); + add("signer_validity", fromOk && untilOk, `valid at ${asOf}: from=${fromOk} until=${untilOk}`); + + const tp = doc.transparency; + if (opts.requireTransparency && !tp) add("transparency", false, "missing"); + if (tp) { + const eok = + tp.entryData?.type === "evidence_bundle" && + tp.entryData.bundleId === core.bundleId && + tp.entryData.evidenceRoot === doc.evidenceRoot && + tp.entryData.signerKeyId === core.signer.keyId; + add("transparency_entry", eok, eok ? "binds bundleId+evidenceRoot+signerKeyId" : "MISMATCH"); + + add( + "transparency_inclusion", + verifyInclusion(logLeafHashHex(tp.entryData), tp.entryIndex, tp.treeSize, tp.inclusionProof, tp.rootHash), + `entry ${tp.entryIndex} in tree ${tp.treeSize}`, + ); + + const sth = tp.signedTreeHead; + const sthShapeOk = sth.treeSize === tp.treeSize && sth.rootHash === tp.rootHash; + const sthMsg = cbytes({ treeSize: sth.treeSize, rootHash: sth.rootHash, timestamp: sth.timestamp }); + add("signed_tree_head", sthShapeOk && verifyRaw(sth.logPublicKeyHex, sthMsg, sth.signature), sthShapeOk ? "signed" : "shape mismatch"); + + if (tp.keyRegistration) { + const kr = tp.keyRegistration; + const dataOk = + kr.entryData.type === "key_registration" && + kr.entryData.keyId === core.signer.keyId && + kr.entryData.publicKeyHex === core.signer.publicKeyHex && + kr.entryData.algorithm === core.signer.algorithm && + kr.entryData.rotatesKeyId === core.signer.rotatesKeyId; + const inclOk = verifyInclusion(logLeafHashHex(kr.entryData), kr.entryIndex, tp.treeSize, kr.inclusionProof, tp.rootHash); + add("key_registration", dataOk && inclOk, dataOk && inclOk ? `registered @ ${kr.entryIndex}` : "INVALID"); + } + if (tp.keyRevocation) { + const rev = tp.keyRevocation; + const inclOk = verifyInclusion(logLeafHashHex(rev.entryData), rev.entryIndex, tp.treeSize, rev.inclusionProof, tp.rootHash); + const afterUse = new Date(rev.entryData.revokedAt) >= new Date(core.createdAt); + add("key_revocation", inclOk && afterUse, inclOk ? `revoked ${rev.entryData.revokedAt} (${afterUse ? "after" : "BEFORE"} signing)` : "INVALID"); + } + } + + return { valid: checks.every((c) => c.ok), bundleId: core?.bundleId, evidenceRoot: doc.evidenceRoot, checks }; +} + +// ── CLI ────────────────────────────────────────────────────────────────── +const arg = process.argv[2]; +if (!arg) { + console.error("usage: verify-evidence-bundle.mjs "); + process.exit(2); +} +let raw; +try { + raw = arg === "-" ? readFileSync(0, "utf8") : readFileSync(arg, "utf8"); +} catch (e) { + console.error(`cannot read input: ${e.message}`); + process.exit(2); +} +let payload; +try { + payload = JSON.parse(raw); +} catch (e) { + console.error(`invalid JSON: ${e.message}`); + process.exit(2); +} +// Accept either a bare document or an API envelope ({ data: }). +const doc = payload.core ? payload : payload.data; +if (!doc || !doc.core) { + console.error("input does not look like an evidence bundle document"); + process.exit(2); +} + +const result = verifyEvidenceBundle(doc, { requireTransparency: Boolean(doc.transparency) }); +for (const c of result.checks) { + console.log(`${c.ok ? "PASS" : "FAIL"} ${c.name.padEnd(24)} ${c.detail}`); +} +console.log(`\nbundle ${result.bundleId}`); +console.log(`evidence root ${result.evidenceRoot}`); +console.log(result.valid ? "\nBUNDLE VALID" : "\nBUNDLE INVALID"); +process.exit(result.valid ? 0 : 1); diff --git a/backend/src/api/routes/evidenceBundle.routes.ts b/backend/src/api/routes/evidenceBundle.routes.ts new file mode 100644 index 00000000..ed2480d2 --- /dev/null +++ b/backend/src/api/routes/evidenceBundle.routes.ts @@ -0,0 +1,235 @@ +/** + * #1019 — Signed evidence bundles & append-only transparency log. + * + * Mounted at /api/v1/evidence. + * + * POST /bundles create a signed bundle for a report/export + * GET /bundles list bundles (filter by subject) + * GET /bundles/:bundleId full bundle document (verifies offline) + * GET /bundles/:bundleId/disclose partial-disclosure view (?sections=a,b&outputs=x) + * GET /bundles/:bundleId/verify server-side offline verification report + * POST /bundles/verify stateless verification of a supplied document + * GET /log transparency-log entries + * GET /log/checkpoint latest signed tree head + * GET /log/proof/inclusion ?logIndex=&treeSize= + * GET /log/proof/consistency ?first=&second= + * GET /log/keys signer keys with rotation / revocation + * POST /log/keys/rotate rotate the active bundle signer + * POST /log/keys/:keyId/revoke revoke a signer key + */ + +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { evidenceBundleService } from "../../services/evidenceBundle.service.js"; +import { verifyEvidenceBundle } from "../../services/transparencyLog/evidenceBundle.js"; + +const sectionSchema = z.object({ + sectionId: z.string().min(1).max(200), + mediaType: z.string().max(120).optional(), + label: z.string().max(200).optional(), + value: z.unknown(), + saltHex: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(), +}); + +const createBundleSchema = z.object({ + subject: z.object({ + type: z.string().min(1).max(80), + id: z.string().min(1).max(120), + reportType: z.string().max(80).optional(), + periodStart: z.string().optional(), + periodEnd: z.string().optional(), + }), + sections: z.array(sectionSchema).min(1).max(1000), + finalityMetadata: z + .object({ + chain: z.string(), + observedLedger: z.union([z.number(), z.string()]), + finalizedLedger: z.union([z.number(), z.string()]), + confirmations: z.number(), + finalityThreshold: z.number(), + finalized: z.boolean(), + observedAt: z.string(), + }) + .nullish(), + decoderVersions: z.record(z.string()).optional(), + codeVersion: z.record(z.unknown()).nullish(), + configVersion: z.record(z.unknown()).nullish(), + queryParameters: z.record(z.unknown()).optional(), + derivedOutputs: z + .array( + z.object({ + outputId: z.string().min(1).max(200), + label: z.string().max(200).optional(), + mediaType: z.string().max(120).optional(), + value: z.unknown().optional(), + outputHash: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(), + }), + ) + .max(500) + .optional(), + createdBy: z.string().max(120).optional(), +}); + +function parseList(v: unknown): string[] | undefined { + if (typeof v !== "string" || !v.trim()) return undefined; + return v.split(",").map((s) => s.trim()).filter(Boolean); +} + +export async function evidenceBundleRoutes(server: FastifyInstance) { + // ── Bundles ──────────────────────────────────────────────────────────── + + server.post("/bundles", async (request, reply) => { + const parsed = createBundleSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: "Bad Request", details: parsed.error.flatten() }); + } + try { + const result = await evidenceBundleService.createBundle(parsed.data as any); + return reply.status(201).send({ data: result }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/bundles", async (request, reply) => { + const { subjectType, subjectId, limit, offset } = request.query as Record; + const data = await evidenceBundleService.listBundles({ + subjectType, + subjectId, + limit: limit ? parseInt(limit, 10) : undefined, + offset: offset ? parseInt(offset, 10) : undefined, + }); + return reply.send({ data }); + }); + + server.get("/bundles/:bundleId", async (request, reply) => { + const { bundleId } = request.params as { bundleId: string }; + const { treeSize } = request.query as Record; + try { + const doc = await evidenceBundleService.getBundleDocument(bundleId, { + treeSize: treeSize ? parseInt(treeSize, 10) : undefined, + }); + return reply.send({ data: doc, evidenceRoot: doc.evidenceRoot }); + } catch (err: any) { + return reply.status(404).send({ error: "Not Found", message: err.message }); + } + }); + + server.get("/bundles/:bundleId/disclose", async (request, reply) => { + const { bundleId } = request.params as { bundleId: string }; + const q = request.query as Record; + const sections = parseList(q.sections) ?? []; + try { + const doc = await evidenceBundleService.getBundleDocument(bundleId, { + discloseSectionIds: sections, + discloseOutputIds: parseList(q.outputs), + treeSize: q.treeSize ? parseInt(q.treeSize, 10) : undefined, + }); + return reply.send({ data: doc, evidenceRoot: doc.evidenceRoot }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/bundles/:bundleId/verify", async (request, reply) => { + const { bundleId } = request.params as { bundleId: string }; + try { + const result = await evidenceBundleService.verifyBundle(bundleId); + return reply.status(result.valid ? 200 : 422).send({ data: result }); + } catch (err: any) { + return reply.status(404).send({ error: "Not Found", message: err.message }); + } + }); + + server.post("/bundles/verify", async (request, reply) => { + const body = request.body as { document?: unknown; asOf?: string; requireTransparency?: boolean }; + if (!body?.document || typeof body.document !== "object") { + return reply.status(400).send({ error: "Bad Request", message: "document is required" }); + } + try { + const result = verifyEvidenceBundle(body.document as any, { + asOf: body.asOf, + requireTransparency: body.requireTransparency, + }); + return reply.status(result.valid ? 200 : 422).send({ data: result }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + // ── Transparency log ─────────────────────────────────────────────────── + + server.get("/log", async (request, reply) => { + const { entryType, limit, offset } = request.query as Record; + const data = await evidenceBundleService.listLogEntries({ + entryType, + limit: limit ? parseInt(limit, 10) : undefined, + offset: offset ? parseInt(offset, 10) : undefined, + }); + const treeSize = await evidenceBundleService.getCurrentTreeSize(); + return reply.send({ data, treeSize }); + }); + + server.get("/log/checkpoint", async (_request, reply) => { + const checkpoint = await evidenceBundleService.getLatestCheckpoint(); + if (!checkpoint) return reply.status(404).send({ error: "Not Found", message: "log is empty" }); + return reply.send({ data: checkpoint }); + }); + + server.get("/log/proof/inclusion", async (request, reply) => { + const { logIndex, treeSize } = request.query as Record; + if (logIndex === undefined) { + return reply.status(400).send({ error: "Bad Request", message: "logIndex is required" }); + } + try { + const data = await evidenceBundleService.getInclusionProof( + parseInt(logIndex, 10), + treeSize ? parseInt(treeSize, 10) : undefined, + ); + return reply.send({ data }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + server.get("/log/proof/consistency", async (request, reply) => { + const { first, second } = request.query as Record; + if (first === undefined) { + return reply.status(400).send({ error: "Bad Request", message: "first is required" }); + } + try { + const data = await evidenceBundleService.getConsistencyProof( + parseInt(first, 10), + second ? parseInt(second, 10) : undefined, + ); + return reply.send({ data }); + } catch (err: any) { + return reply.status(400).send({ error: "Bad Request", message: err.message }); + } + }); + + // ── Signer keys ──────────────────────────────────────────────────────── + + server.get("/log/keys", async (request, reply) => { + const { purpose } = request.query as Record; + const data = await evidenceBundleService.listSigningKeys(purpose); + return reply.send({ data }); + }); + + server.post("/log/keys/rotate", async (request, reply) => { + const body = (request.body ?? {}) as { rotatedBy?: string }; + const data = await evidenceBundleService.rotateSigner(body.rotatedBy); + return reply.status(201).send({ data }); + }); + + server.post("/log/keys/:keyId/revoke", async (request, reply) => { + const { keyId } = request.params as { keyId: string }; + const body = (request.body ?? {}) as { reason?: string; revokedBy?: string }; + try { + const data = await evidenceBundleService.revokeSigner(keyId, body.reason ?? "unspecified", body.revokedBy); + return reply.send({ data }); + } catch (err: any) { + return reply.status(404).send({ error: "Not Found", message: err.message }); + } + }); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 8a52bcf4..5b50b45f 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -21,6 +21,8 @@ import { registerOperationalMonitoringRoutes } from "./route-groups/operational- import { registerLiquidityRoutes } from "./route-groups/liquidity-routes.js"; import { sorobanEventsRoutes } from "./sorobanEvents.routes.js"; import { backfillRoutes } from "./backfill.routes.js"; +// #1019 — Signed evidence bundles & append-only transparency log +import { evidenceBundleRoutes } from "./evidenceBundle.routes.js"; export async function registerRoutes(server: FastifyInstance): Promise { // Core routes: health, websocket, config, preferences, caching @@ -85,4 +87,7 @@ export async function registerRoutes(server: FastifyInstance): Promise { server.register(sorobanEventsRoutes, { prefix: "/api/v1/soroban-events" }); server.register(backfillRoutes, { prefix: "/api/v1/backfill" }); + + // #1019 — Signed evidence bundles & append-only transparency log + server.register(evidenceBundleRoutes, { prefix: "/api/v1/evidence" }); } diff --git a/backend/src/api/routes/route-groups/admin-routes.ts b/backend/src/api/routes/route-groups/admin-routes.ts index 0aff7d67..574effc8 100644 --- a/backend/src/api/routes/route-groups/admin-routes.ts +++ b/backend/src/api/routes/route-groups/admin-routes.ts @@ -163,13 +163,6 @@ export async function registerAdminRoutes(server: FastifyInstance): Promise { + await knex.schema.createTable("evidence_bundle_signing_keys", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("key_id", 100).notNullable().unique(); + table.string("algorithm", 40).notNullable().defaultTo("ed25519"); + table.string("purpose", 40).notNullable().defaultTo("bundle_signer"); // bundle_signer | log + table.text("public_key_hex").notNullable(); + table.text("private_key_hex").notNullable(); // never returned by the API + table.string("status", 20).notNullable().defaultTo("active"); // active | superseded | revoked + table.timestamp("valid_from", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("valid_until", { useTz: true }); + table.string("rotates_key_id", 100); + table.string("superseded_by_key_id", 100); + table.timestamp("revoked_at", { useTz: true }); + table.text("revocation_reason"); + table.bigInteger("log_entry_index"); + table.string("created_by", 120); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["purpose", "status"]); + }); + + await knex.schema.createTable("transparency_log_entries", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("log_id", 60).notNullable().defaultTo("primary"); + table.bigInteger("log_index").notNullable(); + table.string("entry_type", 40).notNullable(); // evidence_bundle | key_registration | key_revocation + table.text("leaf_hash").notNullable(); + table.jsonb("entry_data").notNullable(); + table.bigInteger("tree_size").notNullable(); + table.text("root_hash").notNullable(); + table.string("bundle_id", 100); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.unique(["log_id", "log_index"]); + table.index(["log_id", "entry_type"]); + table.index(["bundle_id"]); + }); + + await knex.schema.createTable("transparency_log_checkpoints", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("log_id", 60).notNullable().defaultTo("primary"); + table.bigInteger("tree_size").notNullable(); + table.text("root_hash").notNullable(); + table.timestamp("timestamp", { useTz: true }).notNullable(); + table.string("log_key_id", 100).notNullable(); + table.text("log_public_key_hex").notNullable(); + table.text("signature").notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.unique(["log_id", "tree_size"]); + }); + + await knex.schema.createTable("evidence_bundles", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("bundle_id", 100).notNullable().unique(); + table.string("subject_type", 80).notNullable(); + table.string("subject_id", 120).notNullable(); + table.string("report_type", 80); + table.timestamp("period_start", { useTz: true }); + table.timestamp("period_end", { useTz: true }); + table.string("bundle_format_version", 20).notNullable().defaultTo("1.0"); + table.text("evidence_root").notNullable(); + table.text("inputs_root").notNullable(); + table.text("signature").notNullable(); + table.string("signer_key_id", 100).notNullable(); + table.jsonb("core_json").notNullable(); + table.jsonb("disclosed_sections_json").notNullable().defaultTo(knex.raw("'[]'::jsonb")); + table.jsonb("disclosed_outputs_json").notNullable().defaultTo(knex.raw("'[]'::jsonb")); + table.bigInteger("log_entry_index"); + table.string("created_by", 120); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.index(["subject_type", "subject_id"]); + table.index(["evidence_root"]); + table.index(["created_at"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("evidence_bundles"); + await knex.schema.dropTableIfExists("transparency_log_checkpoints"); + await knex.schema.dropTableIfExists("transparency_log_entries"); + await knex.schema.dropTableIfExists("evidence_bundle_signing_keys"); +} diff --git a/backend/src/services/evidenceBundle.service.ts b/backend/src/services/evidenceBundle.service.ts new file mode 100644 index 00000000..199c3397 --- /dev/null +++ b/backend/src/services/evidenceBundle.service.ts @@ -0,0 +1,582 @@ +/** + * Signed evidence bundles + append-only transparency log (issue #1019). + * + * Database wrapper around the pure primitives in + * `src/services/transparencyLog/*`. Responsibilities: + * + * - manage rotating Ed25519 signer keys and record their lifecycle + * (registration / rotation / revocation) as transparency-log entries + * - append bundle commitments to a sequential RFC 6962 Merkle log and + * publish a signed tree head (checkpoint) for every new size + * - assemble a self-contained bundle document (canonical core + disclosed + * material + inclusion proof + signed tree head) that verifies offline + * - serve inclusion and consistency proofs + */ + +import { randomUUID } from "crypto"; +import type { Knex } from "knex"; +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; +import { + canonicalBytes, +} from "./transparencyLog/canonical.js"; +import { + consistencyProofFromLeafHashes, + inclusionProofFromLeafHashes, + rootHexFromLeafHashes, + verifyConsistencyProof, +} from "./transparencyLog/merkle.js"; +import { generateRawKeyPair, signRaw } from "./transparencyLog/ed25519.js"; +import { + buildBundleCore, + bundleLogEntryData, + computeEvidenceRoot, + discloseSubset, + logLeafHashHex, + signedTreeHeadMessage, + signEvidenceRoot, + verifyEvidenceBundle, + type BuildBundleInput, + type EvidenceBundleDocument, + type KeyRegistrationEntryData, + type KeyRevocationEntryData, + type SignerMetadata, + type TransparencyProof, + type VerificationResult, +} from "./transparencyLog/evidenceBundle.js"; + +const LOG_ID = "primary"; +// Stable key for pg_advisory_xact_lock so concurrent appends serialize. +const APPEND_LOCK_KEY = 761_901_019; + +export interface CreateBundleRequest { + subject: BuildBundleInput["subject"]; + sections: BuildBundleInput["sections"]; + finalityMetadata?: BuildBundleInput["finalityMetadata"]; + decoderVersions?: Record; + codeVersion?: Record | null; + configVersion?: Record | null; + queryParameters?: Record; + derivedOutputs?: BuildBundleInput["derivedOutputs"]; + createdBy?: string; +} + +export interface SigningKeyView { + keyId: string; + algorithm: string; + purpose: string; + publicKeyHex: string; + status: "active" | "superseded" | "revoked"; + validFrom: string; + validUntil: string | null; + rotatesKeyId: string | null; + supersededByKeyId: string | null; + revokedAt: string | null; + revocationReason: string | null; + logEntryIndex: number | null; + createdAt: string; +} + +export class EvidenceBundleService { + private db(): Knex { + return getDatabase(); + } + + // ── Signing keys ──────────────────────────────────────────────────────── + + private keyView(row: any): SigningKeyView { + return { + keyId: row.key_id, + algorithm: row.algorithm, + purpose: row.purpose, + publicKeyHex: row.public_key_hex, + status: row.status, + validFrom: toIso(row.valid_from), + validUntil: row.valid_until ? toIso(row.valid_until) : null, + rotatesKeyId: row.rotates_key_id ?? null, + supersededByKeyId: row.superseded_by_key_id ?? null, + revokedAt: row.revoked_at ? toIso(row.revoked_at) : null, + revocationReason: row.revocation_reason ?? null, + logEntryIndex: row.log_entry_index === null || row.log_entry_index === undefined ? null : Number(row.log_entry_index), + createdAt: toIso(row.created_at), + }; + } + + async listSigningKeys(purpose?: string): Promise { + let q = this.db()("evidence_bundle_signing_keys").orderBy("created_at", "asc"); + if (purpose) q = q.where({ purpose }); + return (await q).map((r) => this.keyView(r)); + } + + /** The Ed25519 key used to sign new bundles; created on first use. */ + async getActiveSigner(purpose: "bundle_signer" | "log" = "bundle_signer"): Promise { + const existing = await this.db()("evidence_bundle_signing_keys") + .where({ purpose, status: "active" }) + .orderBy("valid_from", "desc") + .first(); + if (existing) return existing; + return this.provisionKey(purpose, null, null); + } + + private async provisionKey( + purpose: "bundle_signer" | "log", + rotatesKeyId: string | null, + createdBy: string | null, + ): Promise { + const { privateKeyHex, publicKeyHex } = generateRawKeyPair(); + const keyId = `ebk_${purpose === "log" ? "log_" : ""}${randomUUID().replace(/-/g, "").slice(0, 20)}`; + const [row] = await this.db()("evidence_bundle_signing_keys") + .insert({ + key_id: keyId, + algorithm: "ed25519", + purpose, + public_key_hex: publicKeyHex, + private_key_hex: privateKeyHex, + status: "active", + rotates_key_id: rotatesKeyId, + created_by: createdBy, + }) + .returning("*"); + + // A bundle signer's existence is itself logged so verifiers can prove the + // key was registered before it signed anything. + if (purpose === "bundle_signer") { + const entryData: KeyRegistrationEntryData = { + type: "key_registration", + keyId, + algorithm: "ed25519", + publicKeyHex, + validFrom: toIso(row.valid_from), + rotatesKeyId, + }; + const appended = await this.appendLogEntry("key_registration", entryData); + await this.db()("evidence_bundle_signing_keys") + .where({ key_id: keyId }) + .update({ log_entry_index: appended.logIndex }); + row.log_entry_index = appended.logIndex; + } + + logger.info({ keyId, purpose, rotatesKeyId }, "Provisioned evidence bundle signing key"); + return row; + } + + async rotateSigner(createdBy?: string): Promise { + const current = await this.getActiveSigner("bundle_signer"); + const now = this.db().fn.now(); + const fresh = await this.provisionKey("bundle_signer", current.key_id, createdBy ?? null); + await this.db()("evidence_bundle_signing_keys").where({ key_id: current.key_id }).update({ + status: "superseded", + superseded_by_key_id: fresh.key_id, + valid_until: now, + }); + logger.info({ from: current.key_id, to: fresh.key_id }, "Rotated evidence bundle signer"); + return this.keyView(fresh); + } + + async revokeSigner(keyId: string, reason: string, revokedBy?: string): Promise { + const row = await this.db()("evidence_bundle_signing_keys").where({ key_id: keyId }).first(); + if (!row) throw new Error(`signing key ${keyId} not found`); + + const revokedAt = new Date().toISOString(); + const entryData: KeyRevocationEntryData = { + type: "key_revocation", + keyId, + revokedAt, + reason: reason || "unspecified", + }; + await this.appendLogEntry("key_revocation", entryData); + + const [updated] = await this.db()("evidence_bundle_signing_keys") + .where({ key_id: keyId }) + .update({ + status: "revoked", + revoked_at: revokedAt, + revocation_reason: reason || "unspecified", + valid_until: row.valid_until ?? revokedAt, + created_by: row.created_by, + }) + .returning("*"); + + logger.warn({ keyId, reason, revokedBy }, "Revoked evidence bundle signer"); + return this.keyView(updated); + } + + // ── Transparency log append ───────────────────────────────────────────── + + async appendLogEntry( + entryType: "evidence_bundle" | "key_registration" | "key_revocation", + entryData: unknown, + bundleId?: string, + ): Promise<{ logIndex: number; treeSize: number; rootHash: string; leafHash: string }> { + return this.db().transaction(async (trx) => { + await trx.raw("SELECT pg_advisory_xact_lock(?)", [APPEND_LOCK_KEY]); + + const priorRows = await trx("transparency_log_entries") + .where({ log_id: LOG_ID }) + .orderBy("log_index", "asc") + .select("leaf_hash"); + const priorLeaves = priorRows.map((r) => r.leaf_hash as string); + + const logIndex = priorLeaves.length; + const leafHash = logLeafHashHex(entryData); + const leaves = [...priorLeaves, leafHash]; + const treeSize = leaves.length; + const rootHash = rootHexFromLeafHashes(leaves); + + await trx("transparency_log_entries").insert({ + id: randomUUID(), + log_id: LOG_ID, + log_index: logIndex, + entry_type: entryType, + leaf_hash: leafHash, + entry_data: JSON.stringify(entryData), + tree_size: treeSize, + root_hash: rootHash, + bundle_id: bundleId ?? null, + }); + + await this.publishCheckpoint(trx, treeSize, rootHash); + + return { logIndex, treeSize, rootHash, leafHash }; + }); + } + + private async publishCheckpoint(trx: Knex.Transaction, treeSize: number, rootHash: string): Promise { + // Log checkpoints are signed with a dedicated "log" key (distinct from the + // bundle signer) so tree-head trust and bundle-authorship trust are separable. + let logKey = await trx("evidence_bundle_signing_keys") + .where({ purpose: "log", status: "active" }) + .orderBy("valid_from", "desc") + .first(); + if (!logKey) { + const { privateKeyHex, publicKeyHex } = generateRawKeyPair(); + const keyId = `ebk_log_${randomUUID().replace(/-/g, "").slice(0, 20)}`; + [logKey] = await trx("evidence_bundle_signing_keys") + .insert({ + key_id: keyId, + algorithm: "ed25519", + purpose: "log", + public_key_hex: publicKeyHex, + private_key_hex: privateKeyHex, + status: "active", + }) + .returning("*"); + } + + const timestamp = new Date().toISOString(); + const signature = signRaw(logKey.private_key_hex, signedTreeHeadMessage({ treeSize, rootHash, timestamp })); + + await trx("transparency_log_checkpoints") + .insert({ + id: randomUUID(), + log_id: LOG_ID, + tree_size: treeSize, + root_hash: rootHash, + timestamp, + log_key_id: logKey.key_id, + log_public_key_hex: logKey.public_key_hex, + signature, + }) + .onConflict(["log_id", "tree_size"]) + .ignore(); + } + + // ── Bundle creation ──────────────────────────────────────────────────── + + async createBundle(req: CreateBundleRequest): Promise<{ bundleId: string; evidenceRoot: string; logEntryIndex: number }> { + const signerRow = await this.getActiveSigner("bundle_signer"); + const signer: SignerMetadata = { + keyId: signerRow.key_id, + algorithm: "ed25519", + publicKeyHex: signerRow.public_key_hex, + validFrom: toIso(signerRow.valid_from), + validUntil: signerRow.valid_until ? toIso(signerRow.valid_until) : null, + rotatesKeyId: signerRow.rotates_key_id ?? null, + logEntryIndex: + signerRow.log_entry_index === null || signerRow.log_entry_index === undefined + ? null + : Number(signerRow.log_entry_index), + }; + + const bundleId = `eb_${randomUUID().replace(/-/g, "")}`; + const built = buildBundleCore({ + bundleId, + subject: req.subject, + sections: req.sections, + finalityMetadata: req.finalityMetadata ?? null, + decoderVersions: req.decoderVersions, + codeVersion: req.codeVersion ?? null, + configVersion: req.configVersion ?? null, + queryParameters: req.queryParameters, + derivedOutputs: req.derivedOutputs, + signer, + }); + + const signature = signEvidenceRoot(signerRow.private_key_hex, built.evidenceRoot); + + const appended = await this.appendLogEntry( + "evidence_bundle", + bundleLogEntryData(built.core, built.evidenceRoot), + bundleId, + ); + + await this.db()("evidence_bundles").insert({ + id: randomUUID(), + bundle_id: bundleId, + subject_type: req.subject.type, + subject_id: req.subject.id, + report_type: req.subject.reportType ?? null, + period_start: req.subject.periodStart ?? null, + period_end: req.subject.periodEnd ?? null, + bundle_format_version: built.core.bundleFormatVersion, + evidence_root: built.evidenceRoot, + inputs_root: built.core.inputsRoot, + signature, + signer_key_id: signer.keyId, + core_json: JSON.stringify(built.core), + disclosed_sections_json: JSON.stringify(built.disclosedSections), + disclosed_outputs_json: JSON.stringify(built.disclosedOutputs), + log_entry_index: appended.logIndex, + created_by: req.createdBy ?? null, + }); + + logger.info( + { bundleId, evidenceRoot: built.evidenceRoot, logEntryIndex: appended.logIndex, subject: req.subject }, + "Created signed evidence bundle", + ); + + return { bundleId, evidenceRoot: built.evidenceRoot, logEntryIndex: appended.logIndex }; + } + + // ── Bundle retrieval / disclosure ────────────────────────────────────── + + private async loadBundleRow(bundleId: string): Promise { + const row = await this.db()("evidence_bundles").where({ bundle_id: bundleId }).first(); + if (!row) throw new Error(`evidence bundle ${bundleId} not found`); + return row; + } + + async getBundleDocument( + bundleId: string, + opts: { discloseSectionIds?: string[]; discloseOutputIds?: string[]; treeSize?: number } = {}, + ): Promise { + const row = await this.loadBundleRow(bundleId); + const core = parseJson(row.core_json); + const evidenceRoot: string = row.evidence_root; + + const full: EvidenceBundleDocument = { + core, + evidenceRoot, + signature: row.signature, + disclosedSections: parseJson(row.disclosed_sections_json), + redactedSectionIds: [], + disclosedOutputs: parseJson(row.disclosed_outputs_json), + transparency: await this.buildTransparencyProof(core, evidenceRoot, Number(row.log_entry_index), opts.treeSize), + }; + + if (opts.discloseSectionIds) { + return discloseSubset(full, opts.discloseSectionIds, opts.discloseOutputIds); + } + return full; + } + + private async buildTransparencyProof( + core: EvidenceBundleDocument["core"], + evidenceRoot: string, + entryIndex: number, + treeSize?: number, + ): Promise { + if (entryIndex === null || entryIndex === undefined || Number.isNaN(entryIndex)) return null; + + const checkpoint = await (treeSize + ? this.db()("transparency_log_checkpoints").where({ log_id: LOG_ID, tree_size: treeSize }).first() + : this.db()("transparency_log_checkpoints").where({ log_id: LOG_ID }).orderBy("tree_size", "desc").first()); + if (!checkpoint) return null; + + const size = Number(checkpoint.tree_size); + const leafRows = await this.db()("transparency_log_entries") + .where({ log_id: LOG_ID }) + .andWhere("log_index", "<", size) + .orderBy("log_index", "asc") + .select("log_index", "leaf_hash", "entry_data", "entry_type"); + const leaves = leafRows.map((r) => r.leaf_hash as string); + + const proof: TransparencyProof = { + logId: LOG_ID, + entryIndex, + entryData: bundleLogEntryData(core, evidenceRoot), + treeSize: size, + rootHash: checkpoint.root_hash, + inclusionProof: inclusionProofFromLeafHashes(entryIndex, leaves), + signedTreeHead: { + treeSize: size, + rootHash: checkpoint.root_hash, + timestamp: toIso(checkpoint.timestamp), + logPublicKeyHex: checkpoint.log_public_key_hex, + signature: checkpoint.signature, + }, + }; + + // Attach the signer key's registration (and revocation, if any) so a + // verifier can confirm the key lifecycle straight from the log. + const krRow = leafRows.find( + (r) => r.entry_type === "key_registration" && parseJson(r.entry_data)?.keyId === core.signer.keyId, + ); + if (krRow) { + proof.keyRegistration = { + entryIndex: Number(krRow.log_index), + entryData: parseJson(krRow.entry_data) as KeyRegistrationEntryData, + inclusionProof: inclusionProofFromLeafHashes(Number(krRow.log_index), leaves), + }; + } + const revRow = leafRows.find( + (r) => r.entry_type === "key_revocation" && parseJson(r.entry_data)?.keyId === core.signer.keyId, + ); + if (revRow) { + proof.keyRevocation = { + entryIndex: Number(revRow.log_index), + entryData: parseJson(revRow.entry_data) as KeyRevocationEntryData, + inclusionProof: inclusionProofFromLeafHashes(Number(revRow.log_index), leaves), + }; + } + + return proof; + } + + async verifyBundle(bundleId: string): Promise { + const doc = await this.getBundleDocument(bundleId); + return verifyEvidenceBundle(doc, { requireTransparency: true }); + } + + // ── Proofs / log reads ───────────────────────────────────────────────── + + async getLatestCheckpoint(): Promise { + const cp = await this.db()("transparency_log_checkpoints") + .where({ log_id: LOG_ID }) + .orderBy("tree_size", "desc") + .first(); + if (!cp) return null; + return { + logId: LOG_ID, + treeSize: Number(cp.tree_size), + rootHash: cp.root_hash, + timestamp: toIso(cp.timestamp), + logKeyId: cp.log_key_id, + logPublicKeyHex: cp.log_public_key_hex, + signature: cp.signature, + }; + } + + private async leafHashesUpTo(size: number): Promise { + const rows = await this.db()("transparency_log_entries") + .where({ log_id: LOG_ID }) + .andWhere("log_index", "<", size) + .orderBy("log_index", "asc") + .select("leaf_hash"); + return rows.map((r) => r.leaf_hash as string); + } + + async getCurrentTreeSize(): Promise { + const row = await this.db()("transparency_log_entries").where({ log_id: LOG_ID }).max("log_index as maxIndex").first(); + const max = row?.maxIndex; + return max === null || max === undefined ? 0 : Number(max) + 1; + } + + async getInclusionProof(logIndex: number, treeSize?: number): Promise<{ + logIndex: number; + treeSize: number; + leafHash: string; + rootHash: string; + inclusionProof: string[]; + }> { + const size = treeSize ?? (await this.getCurrentTreeSize()); + if (logIndex < 0 || logIndex >= size) throw new Error(`logIndex ${logIndex} not in tree of size ${size}`); + const leaves = await this.leafHashesUpTo(size); + return { + logIndex, + treeSize: size, + leafHash: leaves[logIndex], + rootHash: rootHexFromLeafHashes(leaves), + inclusionProof: inclusionProofFromLeafHashes(logIndex, leaves), + }; + } + + async getConsistencyProof(firstSize: number, secondSize?: number): Promise<{ + firstSize: number; + secondSize: number; + firstRoot: string; + secondRoot: string; + consistencyProof: string[]; + valid: boolean; + }> { + const second = secondSize ?? (await this.getCurrentTreeSize()); + if (firstSize <= 0 || firstSize > second) throw new Error(`firstSize ${firstSize} out of range (0, ${second}]`); + const secondLeaves = await this.leafHashesUpTo(second); + const firstLeaves = secondLeaves.slice(0, firstSize); + const firstRoot = rootHexFromLeafHashes(firstLeaves); + const secondRoot = rootHexFromLeafHashes(secondLeaves); + const consistencyProof = consistencyProofFromLeafHashes(firstSize, secondLeaves); + return { + firstSize, + secondSize: second, + firstRoot, + secondRoot, + consistencyProof, + valid: verifyConsistencyProof(firstSize, second, consistencyProof, firstRoot, secondRoot), + }; + } + + async listLogEntries(opts: { entryType?: string; limit?: number; offset?: number } = {}): Promise { + let q = this.db()("transparency_log_entries").where({ log_id: LOG_ID }).orderBy("log_index", "asc"); + if (opts.entryType) q = q.where({ entry_type: opts.entryType }); + q = q.offset(opts.offset ?? 0).limit(Math.min(opts.limit ?? 100, 1000)); + return (await q).map((r) => ({ + logIndex: Number(r.log_index), + entryType: r.entry_type, + leafHash: r.leaf_hash, + entryData: parseJson(r.entry_data), + treeSize: Number(r.tree_size), + rootHash: r.root_hash, + bundleId: r.bundle_id, + createdAt: toIso(r.created_at), + })); + } + + async listBundles(opts: { subjectType?: string; subjectId?: string; limit?: number; offset?: number } = {}): Promise { + let q = this.db()("evidence_bundles").orderBy("created_at", "desc"); + if (opts.subjectType) q = q.where({ subject_type: opts.subjectType }); + if (opts.subjectId) q = q.where({ subject_id: opts.subjectId }); + q = q.offset(opts.offset ?? 0).limit(Math.min(opts.limit ?? 50, 500)); + return (await q).map((r) => ({ + bundleId: r.bundle_id, + subjectType: r.subject_type, + subjectId: r.subject_id, + reportType: r.report_type, + evidenceRoot: r.evidence_root, + inputsRoot: r.inputs_root, + signerKeyId: r.signer_key_id, + logEntryIndex: r.log_entry_index === null ? null : Number(r.log_entry_index), + createdAt: toIso(r.created_at), + })); + } +} + +// Helpers ──────────────────────────────────────────────────────────────── + +function toIso(v: unknown): string { + if (v instanceof Date) return v.toISOString(); + if (typeof v === "string") return new Date(v).toISOString(); + if (typeof v === "number") return new Date(v).toISOString(); + return new Date().toISOString(); +} + +function parseJson(v: unknown): any { + if (v === null || v === undefined) return null; + if (typeof v === "string") return JSON.parse(v); + return v; +} + +// Re-export canonical bytes for callers that need to hash their own outputs. +export { canonicalBytes, computeEvidenceRoot, verifyEvidenceBundle }; + +export const evidenceBundleService = new EvidenceBundleService(); diff --git a/backend/src/services/transparencyLog/canonical.ts b/backend/src/services/transparencyLog/canonical.ts new file mode 100644 index 00000000..1f67a9fa --- /dev/null +++ b/backend/src/services/transparencyLog/canonical.ts @@ -0,0 +1,55 @@ +/** + * Deterministic ("canonical") JSON serialization. + * + * Two verifiers on different machines must derive byte-identical bytes from the + * same logical value, otherwise hash commitments and signatures over a bundle + * are not portable. Rules (a pragmatic subset of RFC 8785 / JCS): + * + * - object keys are emitted in ascending UTF-16 code-unit order + * - no insignificant whitespace + * - `undefined` object properties are dropped; `undefined` array items become null + * - numbers are emitted via the shortest round-trip form (JSON.stringify) + * - non-finite numbers are rejected (they have no portable JSON form) + * + * The same function is duplicated, byte-for-byte, in + * `backend/scripts/verify-evidence-bundle.mjs` so an auditor can verify a bundle + * with nothing but Node's standard library. + */ + +export function canonicalize(value: unknown): string { + if (value === null) return "null"; + + const t = typeof value; + + if (t === "number") { + if (!Number.isFinite(value as number)) { + throw new Error("canonicalize: non-finite number is not serializable"); + } + return JSON.stringify(value); + } + + if (t === "boolean" || t === "string") return JSON.stringify(value); + + if (t === "bigint") return (value as bigint).toString(); + + if (Array.isArray(value)) { + const items = value.map((item) => (item === undefined ? "null" : canonicalize(item))); + return `[${items.join(",")}]`; + } + + if (t === "object") { + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + const entries = keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`); + return `{${entries.join(",")}}`; + } + + throw new Error(`canonicalize: unsupported value of type ${t}`); +} + +/** Canonical bytes (UTF-8) of a value. */ +export function canonicalBytes(value: unknown): Buffer { + return Buffer.from(canonicalize(value), "utf8"); +} diff --git a/backend/src/services/transparencyLog/ed25519.ts b/backend/src/services/transparencyLog/ed25519.ts new file mode 100644 index 00000000..cedd008e --- /dev/null +++ b/backend/src/services/transparencyLog/ed25519.ts @@ -0,0 +1,57 @@ +/** + * Ed25519 helpers over raw 32-byte keys. + * + * Evidence bundles carry signer keys as raw hex (not PEM) so the wire format is + * small and language-neutral. Node's `crypto` only ingests DER/PEM/JWK, so we + * wrap the raw bytes in the fixed Ed25519 SPKI / PKCS#8 prefixes. Duplicated in + * `backend/scripts/verify-evidence-bundle.mjs`. + */ + +import { createPublicKey, createPrivateKey, generateKeyPairSync, sign, verify, type KeyObject } from "crypto"; + +const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); // 12 bytes, then 32-byte public key +const PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); // 16 bytes, then 32-byte seed + +export function publicKeyFromRawHex(hex: string): KeyObject { + const raw = Buffer.from(hex, "hex"); + if (raw.length !== 32) throw new Error(`ed25519 public key must be 32 bytes, got ${raw.length}`); + return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" }); +} + +export function privateKeyFromRawHex(hex: string): KeyObject { + const raw = Buffer.from(hex, "hex"); + if (raw.length !== 32) throw new Error(`ed25519 private key seed must be 32 bytes, got ${raw.length}`); + return createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, raw]), format: "der", type: "pkcs8" }); +} + +export function rawPublicKeyHex(key: KeyObject): string { + const der = key.export({ format: "der", type: "spki" }) as Buffer; + return der.subarray(der.length - 32).toString("hex"); +} + +export function rawPrivateKeyHex(key: KeyObject): string { + const der = key.export({ format: "der", type: "pkcs8" }) as Buffer; + return der.subarray(der.length - 32).toString("hex"); +} + +export interface RawKeyPair { + privateKeyHex: string; + publicKeyHex: string; +} + +export function generateRawKeyPair(): RawKeyPair { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + return { privateKeyHex: rawPrivateKeyHex(privateKey), publicKeyHex: rawPublicKeyHex(publicKey) }; +} + +export function signRaw(privateKeyHex: string, message: Buffer): string { + return sign(null, message, privateKeyFromRawHex(privateKeyHex)).toString("hex"); +} + +export function verifyRaw(publicKeyHex: string, message: Buffer, signatureHex: string): boolean { + try { + return verify(null, message, publicKeyFromRawHex(publicKeyHex), Buffer.from(signatureHex, "hex")); + } catch { + return false; + } +} diff --git a/backend/src/services/transparencyLog/evidenceBundle.ts b/backend/src/services/transparencyLog/evidenceBundle.ts new file mode 100644 index 00000000..feab9516 --- /dev/null +++ b/backend/src/services/transparencyLog/evidenceBundle.ts @@ -0,0 +1,481 @@ +/** + * Signed evidence bundles (issue #1019). + * + * A bundle is a portable, independently verifiable proof of exactly which raw + * observations, code/config versions, decoder versions, query parameters and + * chain-finality metadata produced a report or export. It commits to those + * inputs with a Merkle root, wraps everything in a canonical "core" object, + * and signs the SHA-256 of that core (the *evidence root*) with a rotating + * Ed25519 key whose lifecycle lives in the append-only transparency log. + * + * This module is pure (no database, no I/O). The database wrapper lives in + * `backend/src/services/evidenceBundle.service.ts`; a standalone re-implementation + * for auditors lives in `backend/scripts/verify-evidence-bundle.mjs`. + */ + +import { randomBytes } from "crypto"; +import { canonicalBytes, canonicalize } from "./canonical.js"; +import { hashLeaf, merkleRootHex, sha256, verifyInclusionProof } from "./merkle.js"; +import { signRaw, verifyRaw } from "./ed25519.js"; + +export const BUNDLE_FORMAT_VERSION = "1.0"; + +// ── Wire types ──────────────────────────────────────────────────────────── + +export interface EvidenceSectionInput { + /** Stable identifier, unique within the bundle. */ + sectionId: string; + /** IANA media type of `value` once serialized. Defaults to application/json. */ + mediaType?: string; + label?: string; + /** The raw disclosed content (canonical observations, chain evidence, …). */ + value: unknown; + /** 32-byte hex salt; generated if omitted. Blinds low-entropy redacted values. */ + saltHex?: string; +} + +export interface SectionCommitment { + sectionId: string; + mediaType: string; + label: string; + /** SHA-256(salt || canonical(value)), hex. */ + contentHash: string; +} + +export interface DisclosedSection { + sectionId: string; + mediaType: string; + label: string; + saltHex: string; + value: unknown; +} + +export interface DerivedOutputInput { + outputId: string; + label?: string; + mediaType?: string; + /** Serialized value used to derive the hash; or supply `outputHash` directly. */ + value?: unknown; + /** SHA-256 hex of the output bytes. Computed from `value` when omitted. */ + outputHash?: string; +} + +export interface DerivedOutputCommitment { + outputId: string; + label: string; + mediaType: string; + outputHash: string; +} + +export interface FinalityMetadata { + chain: string; + observedLedger: number | string; + finalizedLedger: number | string; + confirmations: number; + finalityThreshold: number; + finalized: boolean; + observedAt: string; +} + +export interface BundleSubject { + type: string; + id: string; + reportType?: string; + periodStart?: string; + periodEnd?: string; +} + +export interface SignerMetadata { + keyId: string; + algorithm: "ed25519"; + publicKeyHex: string; + validFrom: string; + validUntil: string | null; + /** Predecessor key this key rotated from, if any. */ + rotatesKeyId: string | null; + /** Index of this key's `key_registration` entry in the transparency log. */ + logEntryIndex: number | null; +} + +export interface BundleCore { + bundleId: string; + bundleFormatVersion: string; + subject: BundleSubject; + createdAt: string; + inputsRoot: string; + sectionCommitments: SectionCommitment[]; + finalityMetadata: FinalityMetadata | null; + decoderVersions: Record; + codeVersion: Record | null; + configVersion: Record | null; + queryParameters: Record; + derivedOutputs: DerivedOutputCommitment[]; + signer: SignerMetadata; +} + +export interface SignedTreeHead { + treeSize: number; + rootHash: string; + timestamp: string; + logPublicKeyHex: string; + signature: string; +} + +export interface TransparencyEntryData { + type: "evidence_bundle"; + bundleId: string; + evidenceRoot: string; + signerKeyId: string; +} + +export interface KeyRegistrationEntryData { + type: "key_registration"; + keyId: string; + algorithm: "ed25519"; + publicKeyHex: string; + validFrom: string; + rotatesKeyId: string | null; +} + +export interface KeyRevocationEntryData { + type: "key_revocation"; + keyId: string; + revokedAt: string; + reason: string; +} + +export interface TransparencyProof { + logId: string; + entryIndex: number; + entryData: TransparencyEntryData; + treeSize: number; + rootHash: string; + inclusionProof: string[]; + signedTreeHead: SignedTreeHead; + keyRegistration?: { + entryIndex: number; + entryData: KeyRegistrationEntryData; + inclusionProof: string[]; + }; + keyRevocation?: { + entryIndex: number; + entryData: KeyRevocationEntryData; + inclusionProof: string[]; + }; +} + +export interface EvidenceBundleDocument { + core: BundleCore; + evidenceRoot: string; + signature: string; + disclosedSections: DisclosedSection[]; + redactedSectionIds: string[]; + disclosedOutputs: Array<{ outputId: string; value: unknown }>; + transparency: TransparencyProof | null; +} + +// ── Construction ────────────────────────────────────────────────────────── + +function randomSaltHex(): string { + return randomBytes(32).toString("hex"); +} + +export function sectionContentHash(saltHex: string, value: unknown): string { + return sha256(Buffer.from(saltHex, "hex"), canonicalBytes(value)).toString("hex"); +} + +/** Leaf bytes for a section within the inputs Merkle tree. */ +export function sectionLeafBytes(c: Pick): Buffer { + return canonicalBytes({ sectionId: c.sectionId, mediaType: c.mediaType, contentHash: c.contentHash }); +} + +export function computeInputsRoot(commitments: SectionCommitment[]): string { + return merkleRootHex(commitments.map(sectionLeafBytes)); +} + +export function outputHashOf(value: unknown): string { + return sha256(canonicalBytes(value)).toString("hex"); +} + +export interface BuildBundleInput { + bundleId: string; + subject: BundleSubject; + createdAt?: string; + sections: EvidenceSectionInput[]; + finalityMetadata?: FinalityMetadata | null; + decoderVersions?: Record; + codeVersion?: Record | null; + configVersion?: Record | null; + queryParameters?: Record; + derivedOutputs?: DerivedOutputInput[]; + signer: SignerMetadata; +} + +export interface BuiltBundle { + core: BundleCore; + evidenceRoot: string; + disclosedSections: DisclosedSection[]; + disclosedOutputs: Array<{ outputId: string; value: unknown }>; +} + +/** Build the canonical core + disclosed material. Does not sign. */ +export function buildBundleCore(input: BuildBundleInput): BuiltBundle { + if (!input.sections.length) throw new Error("evidence bundle requires at least one input section"); + const seenIds = new Set(); + + const disclosedSections: DisclosedSection[] = []; + const sectionCommitments: SectionCommitment[] = input.sections.map((s) => { + if (!s.sectionId) throw new Error("section is missing sectionId"); + if (seenIds.has(s.sectionId)) throw new Error(`duplicate sectionId: ${s.sectionId}`); + seenIds.add(s.sectionId); + + const mediaType = s.mediaType ?? "application/json"; + const label = s.label ?? s.sectionId; + const saltHex = s.saltHex ?? randomSaltHex(); + const contentHash = sectionContentHash(saltHex, s.value); + + disclosedSections.push({ sectionId: s.sectionId, mediaType, label, saltHex, value: s.value }); + return { sectionId: s.sectionId, mediaType, label, contentHash }; + }); + + const derivedOutputs: DerivedOutputCommitment[] = (input.derivedOutputs ?? []).map((o) => { + if (!o.outputId) throw new Error("derived output is missing outputId"); + const outputHash = o.outputHash ?? (o.value !== undefined ? outputHashOf(o.value) : undefined); + if (!outputHash) throw new Error(`derived output ${o.outputId} needs a value or an outputHash`); + return { + outputId: o.outputId, + label: o.label ?? o.outputId, + mediaType: o.mediaType ?? "application/json", + outputHash, + }; + }); + + const disclosedOutputs = (input.derivedOutputs ?? []) + .filter((o) => o.value !== undefined) + .map((o) => ({ outputId: o.outputId, value: o.value })); + + const core: BundleCore = { + bundleId: input.bundleId, + bundleFormatVersion: BUNDLE_FORMAT_VERSION, + subject: input.subject, + createdAt: input.createdAt ?? new Date().toISOString(), + inputsRoot: computeInputsRoot(sectionCommitments), + sectionCommitments, + finalityMetadata: input.finalityMetadata ?? null, + decoderVersions: input.decoderVersions ?? {}, + codeVersion: input.codeVersion ?? null, + configVersion: input.configVersion ?? null, + queryParameters: input.queryParameters ?? {}, + derivedOutputs, + signer: input.signer, + }; + + return { core, evidenceRoot: computeEvidenceRoot(core), disclosedSections, disclosedOutputs }; +} + +/** SHA-256 of the canonical core = the evidence root that gets signed & logged. */ +export function computeEvidenceRoot(core: BundleCore): string { + return sha256(canonicalBytes(core)).toString("hex"); +} + +export function signEvidenceRoot(privateKeyHex: string, evidenceRoot: string): string { + return signRaw(privateKeyHex, Buffer.from(evidenceRoot, "hex")); +} + +// ── Transparency-log leaf helpers ───────────────────────────────────────── + +export function bundleLogEntryData(core: BundleCore, evidenceRoot: string): TransparencyEntryData { + return { type: "evidence_bundle", bundleId: core.bundleId, evidenceRoot, signerKeyId: core.signer.keyId }; +} + +export function logLeafHashHex(entryData: unknown): string { + return hashLeaf(canonicalBytes(entryData)).toString("hex"); +} + +export function signedTreeHeadMessage(sth: Pick): Buffer { + return canonicalBytes({ treeSize: sth.treeSize, rootHash: sth.rootHash, timestamp: sth.timestamp }); +} + +// ── Partial disclosure ─────────────────────────────────────────────────── + +/** + * Return a copy of `doc` exposing only `keepSectionIds` (and, when given, only + * `keepOutputIds`). The core, evidence root and signature are untouched, so + * proof validity is preserved. + */ +export function discloseSubset( + doc: EvidenceBundleDocument, + keepSectionIds: string[], + keepOutputIds?: string[], +): EvidenceBundleDocument { + const keep = new Set(keepSectionIds); + const allIds = doc.core.sectionCommitments.map((c) => c.sectionId); + for (const id of keep) { + if (!allIds.includes(id)) throw new Error(`unknown sectionId: ${id}`); + } + + const disclosedSections = doc.disclosedSections.filter((s) => keep.has(s.sectionId)); + const redactedSectionIds = allIds.filter((id) => !keep.has(id)); + + const disclosedOutputs = keepOutputIds + ? doc.disclosedOutputs.filter((o) => keepOutputIds.includes(o.outputId)) + : doc.disclosedOutputs; + + return { ...doc, disclosedSections, redactedSectionIds, disclosedOutputs }; +} + +// ── Offline verification ───────────────────────────────────────────────── + +export interface VerificationCheck { + name: string; + ok: boolean; + detail: string; +} + +export interface VerificationResult { + valid: boolean; + bundleId: string; + evidenceRoot: string; + checks: VerificationCheck[]; +} + +export interface VerifyOptions { + /** ISO timestamp; when set, also assert the signer key was valid at this instant. */ + asOf?: string; + /** Require an embedded transparency proof (default: verify it only if present). */ + requireTransparency?: boolean; +} + +/** + * Validate a bundle with no database and no network. Every commitment is + * recomputed from disclosed material and checked against the signed core; the + * signature and (when present) the transparency-log inclusion + signed tree + * head are verified against keys embedded in the bundle. + */ +export function verifyEvidenceBundle(doc: EvidenceBundleDocument, opts: VerifyOptions = {}): VerificationResult { + const checks: VerificationCheck[] = []; + const add = (name: string, ok: boolean, detail: string) => checks.push({ name, ok, detail }); + const core = doc.core; + + add( + "format", + core?.bundleFormatVersion === BUNDLE_FORMAT_VERSION, + `bundleFormatVersion=${core?.bundleFormatVersion} (supported: ${BUNDLE_FORMAT_VERSION})`, + ); + + // Section id partition: disclosed ∪ redacted == all, and disjoint. + const commitmentIds = (core?.sectionCommitments ?? []).map((c) => c.sectionId); + const disclosedIds = doc.disclosedSections.map((s) => s.sectionId); + const redactedIds = doc.redactedSectionIds ?? []; + const partitionOk = + new Set([...disclosedIds, ...redactedIds]).size === commitmentIds.length && + [...disclosedIds, ...redactedIds].length === commitmentIds.length && + disclosedIds.every((id) => commitmentIds.includes(id)) && + redactedIds.every((id) => commitmentIds.includes(id)); + add( + "section_partition", + partitionOk, + `${disclosedIds.length} disclosed + ${redactedIds.length} redacted vs ${commitmentIds.length} commitments`, + ); + + // Disclosed section content hashes. + let sectionHashesOk = true; + for (const s of doc.disclosedSections) { + const commitment = core.sectionCommitments.find((c) => c.sectionId === s.sectionId); + if (!commitment) { + sectionHashesOk = false; + add("section_hash", false, `disclosed section ${s.sectionId} has no commitment`); + continue; + } + const recomputed = sectionContentHash(s.saltHex, s.value); + const ok = recomputed === commitment.contentHash; + if (!ok) sectionHashesOk = false; + add("section_hash", ok, `${s.sectionId}: ${ok ? "matches" : `expected ${commitment.contentHash}, got ${recomputed}`}`); + } + if (!doc.disclosedSections.length) add("section_hash", true, "no sections disclosed (fully redacted bundle)"); + + // Inputs Merkle root over all commitments (redacted-safe). + const inputsRoot = computeInputsRoot(core.sectionCommitments ?? []); + add("inputs_root", inputsRoot === core.inputsRoot, `recomputed ${inputsRoot} vs core ${core?.inputsRoot}`); + + // Disclosed derived-output hashes. + for (const o of doc.disclosedOutputs ?? []) { + const commitment = core.derivedOutputs.find((d) => d.outputId === o.outputId); + if (!commitment) { + add("output_hash", false, `disclosed output ${o.outputId} has no commitment`); + continue; + } + const recomputed = outputHashOf(o.value); + add("output_hash", recomputed === commitment.outputHash, `${o.outputId}: ${recomputed === commitment.outputHash ? "matches" : "mismatch"}`); + } + + // Evidence root = SHA-256(canonical(core)). + const evidenceRoot = computeEvidenceRoot(core); + const rootOk = evidenceRoot === doc.evidenceRoot; + add("evidence_root", rootOk, `recomputed ${evidenceRoot} vs document ${doc.evidenceRoot}`); + + // Signature over the evidence root. + const sigOk = rootOk && verifyRaw(core.signer.publicKeyHex, Buffer.from(doc.evidenceRoot, "hex"), doc.signature); + add("signature", sigOk, sigOk ? `valid ${core.signer.algorithm} signature by ${core.signer.keyId}` : "signature verification failed"); + + // Signer key validity window. + const asOf = opts.asOf ?? core.createdAt; + const validFromOk = new Date(asOf).getTime() >= new Date(core.signer.validFrom).getTime(); + const validUntilOk = !core.signer.validUntil || new Date(asOf).getTime() <= new Date(core.signer.validUntil).getTime(); + add("signer_validity", validFromOk && validUntilOk, `key ${core.signer.keyId} valid at ${asOf}: from=${validFromOk} until=${validUntilOk}`); + + // Transparency log proof. + const tp = doc.transparency; + if (opts.requireTransparency && !tp) { + add("transparency", false, "no transparency proof embedded"); + } + if (tp) { + const expected = bundleLogEntryData(core, doc.evidenceRoot); + const entryDataOk = + tp.entryData?.type === "evidence_bundle" && + tp.entryData.bundleId === expected.bundleId && + tp.entryData.evidenceRoot === expected.evidenceRoot && + tp.entryData.signerKeyId === expected.signerKeyId; + add("transparency_entry", entryDataOk, entryDataOk ? "log entry binds bundleId + evidenceRoot + signerKeyId" : "log entry data does not match bundle"); + + const leafHex = logLeafHashHex(tp.entryData); + const inclOk = verifyInclusionProof(leafHex, tp.entryIndex, tp.treeSize, tp.inclusionProof, tp.rootHash); + add("transparency_inclusion", inclOk, `entry ${tp.entryIndex} in tree size ${tp.treeSize} @ root ${tp.rootHash.slice(0, 16)}…`); + + const sth = tp.signedTreeHead; + const sthShapeOk = sth.treeSize === tp.treeSize && sth.rootHash === tp.rootHash; + const sthSigOk = sthShapeOk && verifyRaw(sth.logPublicKeyHex, signedTreeHeadMessage(sth), sth.signature); + add("signed_tree_head", sthSigOk, sthSigOk ? `STH signed by log key ${sth.logPublicKeyHex.slice(0, 16)}…` : "signed tree head invalid"); + + if (tp.keyRegistration) { + const kr = tp.keyRegistration; + const krDataOk = + kr.entryData.type === "key_registration" && + kr.entryData.keyId === core.signer.keyId && + kr.entryData.publicKeyHex === core.signer.publicKeyHex && + kr.entryData.algorithm === core.signer.algorithm && + kr.entryData.rotatesKeyId === core.signer.rotatesKeyId; + const krInclOk = verifyInclusionProof(logLeafHashHex(kr.entryData), kr.entryIndex, tp.treeSize, kr.inclusionProof, tp.rootHash); + add("key_registration", krDataOk && krInclOk, krDataOk && krInclOk ? `signer key registered at log entry ${kr.entryIndex}` : "key registration proof invalid"); + } + + if (tp.keyRevocation) { + const rev = tp.keyRevocation; + const revInclOk = verifyInclusionProof(logLeafHashHex(rev.entryData), rev.entryIndex, tp.treeSize, rev.inclusionProof, tp.rootHash); + const revokedAfterUse = new Date(rev.entryData.revokedAt).getTime() >= new Date(core.createdAt).getTime(); + add( + "key_revocation", + revInclOk && revokedAfterUse, + revInclOk + ? `key revoked ${rev.entryData.revokedAt} (${revokedAfterUse ? "after" : "BEFORE"} this bundle was signed)` + : "key revocation proof invalid", + ); + } + } + + const valid = checks.every((c) => c.ok); + return { valid, bundleId: core?.bundleId, evidenceRoot: doc.evidenceRoot, checks }; +} + +/** Convenience: canonical string of any value (re-exported for callers/tests). */ +export { canonicalize }; diff --git a/backend/src/services/transparencyLog/merkle.ts b/backend/src/services/transparencyLog/merkle.ts new file mode 100644 index 00000000..e285c827 --- /dev/null +++ b/backend/src/services/transparencyLog/merkle.ts @@ -0,0 +1,279 @@ +/** + * RFC 6962 ("Certificate Transparency") binary Merkle tree. + * + * The transparency log commits to an ordered list of leaves. This module + * provides the append-only primitives an offline verifier needs: + * + * - the Merkle Tree Hash (root) of the first N leaves + * - inclusion proofs (leaf i is committed by the size-N root) + * - consistency proofs (the size-M root is a prefix of the size-N root) + * + * Domain separation (RFC 6962 §2.1): + * empty tree = SHA-256("") + * leaf hash = SHA-256(0x00 || leaf_data) + * inner hash = SHA-256(0x01 || left_hash || right_hash) + * + * Verification follows the iterative algorithm used by transparency-dev/merkle: + * decompose the proof into `inner` (sibling on the path) and `border` + * (left-spine) components, then chain. It is duplicated in + * `backend/scripts/verify-evidence-bundle.mjs`. + */ + +import { createHash } from "crypto"; + +const LEAF_PREFIX = Buffer.from([0x00]); +const INNER_PREFIX = Buffer.from([0x01]); + +export function sha256(...parts: Buffer[]): Buffer { + const h = createHash("sha256"); + for (const p of parts) h.update(p); + return h.digest(); +} + +export function hashLeaf(data: Buffer): Buffer { + return sha256(LEAF_PREFIX, data); +} + +export function hashChildren(left: Buffer, right: Buffer): Buffer { + return sha256(INNER_PREFIX, left, right); +} + +const toBuf = (h: Buffer | string): Buffer => (typeof h === "string" ? Buffer.from(h, "hex") : h); + +/** Largest power of two strictly smaller than n (n >= 2). */ +function splitPoint(n: number): number { + let k = 1; + while (k << 1 < n) k <<= 1; + return k; +} + +/** + * Merkle Tree Hash of `leaves` (already leaf-level data, not yet hashed). + * Returns the 32-byte root. + */ +export function merkleTreeHash(leaves: Buffer[]): Buffer { + const n = leaves.length; + if (n === 0) return sha256(Buffer.alloc(0)); + if (n === 1) return hashLeaf(leaves[0]); + const k = splitPoint(n); + return hashChildren(merkleTreeHash(leaves.slice(0, k)), merkleTreeHash(leaves.slice(k))); +} + +export function merkleRootHex(leaves: Buffer[]): string { + return merkleTreeHash(leaves).toString("hex"); +} + +/** + * RFC 6962 §2.1.1 inclusion path for leaf `index` within the first `leaves`. + * Ordered leaf-upward. Returns hex sibling hashes. + */ +export function inclusionProof(index: number, leaves: Buffer[]): string[] { + const n = leaves.length; + if (index < 0 || index >= n) throw new Error(`inclusionProof: index ${index} out of range [0, ${n})`); + if (n === 1) return []; + const k = splitPoint(n); + if (index < k) { + return [...inclusionProof(index, leaves.slice(0, k)), merkleTreeHash(leaves.slice(k)).toString("hex")]; + } + return [...inclusionProof(index - k, leaves.slice(k)), merkleTreeHash(leaves.slice(0, k)).toString("hex")]; +} + +/** + * RFC 6962 §2.1.2 consistency proof between tree sizes `m` and `n` (0 < m <= n). + * Returns hex hashes. + */ +export function consistencyProof(m: number, leaves: Buffer[]): string[] { + const n = leaves.length; + if (m <= 0 || m > n) throw new Error(`consistencyProof: m ${m} out of range (0, ${n}]`); + if (m === n) return []; + return subProof(m, leaves, true).map((b) => b.toString("hex")); +} + +function subProof(m: number, leaves: Buffer[], onPath: boolean): Buffer[] { + const n = leaves.length; + if (m === n) { + // The old tree is exactly this subtree. Its root is only supplied when it is + // not already derivable from the caller's context (i.e. not on the spine). + return onPath ? [] : [merkleTreeHash(leaves)]; + } + const k = splitPoint(n); + if (m <= k) { + return [...subProof(m, leaves.slice(0, k), onPath), merkleTreeHash(leaves.slice(k))]; + } + return [...subProof(m - k, leaves.slice(k), false), merkleTreeHash(leaves.slice(0, k))]; +} + +// ── Variants over already-hashed leaves (the transparency log stores these) ─ + +/** Root of a tree whose leaf hashes are supplied directly (no re-hashing). */ +export function rootFromLeafHashes(leafHashes: Array): Buffer { + const hs = leafHashes.map(toBuf); + const n = hs.length; + if (n === 0) return sha256(Buffer.alloc(0)); + if (n === 1) return hs[0]; + const k = splitPoint(n); + return hashChildren(rootFromLeafHashes(hs.slice(0, k)), rootFromLeafHashes(hs.slice(k))); +} + +export function rootHexFromLeafHashes(leafHashes: Array): string { + return rootFromLeafHashes(leafHashes).toString("hex"); +} + +export function inclusionProofFromLeafHashes(index: number, leafHashes: Array): string[] { + const hs = leafHashes.map(toBuf); + const n = hs.length; + if (index < 0 || index >= n) throw new Error(`inclusionProof: index ${index} out of range [0, ${n})`); + if (n === 1) return []; + const k = splitPoint(n); + if (index < k) { + return [...inclusionProofFromLeafHashes(index, hs.slice(0, k)), rootFromLeafHashes(hs.slice(k)).toString("hex")]; + } + return [...inclusionProofFromLeafHashes(index - k, hs.slice(k)), rootFromLeafHashes(hs.slice(0, k)).toString("hex")]; +} + +export function consistencyProofFromLeafHashes(m: number, leafHashes: Array): string[] { + const hs = leafHashes.map(toBuf); + const n = hs.length; + if (m <= 0 || m > n) throw new Error(`consistencyProof: m ${m} out of range (0, ${n}]`); + if (m === n) return []; + return subProofLeafHashes(m, hs, true).map((b) => b.toString("hex")); +} + +function subProofLeafHashes(m: number, hs: Buffer[], onPath: boolean): Buffer[] { + const n = hs.length; + if (m === n) return onPath ? [] : [rootFromLeafHashes(hs)]; + const k = splitPoint(n); + if (m <= k) { + return [...subProofLeafHashes(m, hs.slice(0, k), onPath), rootFromLeafHashes(hs.slice(k))]; + } + return [...subProofLeafHashes(m - k, hs.slice(k), false), rootFromLeafHashes(hs.slice(0, k))]; +} + +// ── Verification (no tree, proof material only) ──────────────────────────── + +const bitLen = (x: number): number => (x === 0 ? 0 : Math.floor(Math.log2(x)) + 1); +const onesCount = (x: number): number => { + let c = 0; + while (x > 0) { + c += x & 1; + x = Math.floor(x / 2); + } + return c; +}; +const trailingZeros = (x: number): number => { + if (x === 0) return 0; + let c = 0; + while ((x & 1) === 0) { + c += 1; + x = Math.floor(x / 2); + } + return c; +}; + +/** [inner, border] decomposition of an inclusion proof for `index` in `size`. */ +function decompose(index: number, size: number): [number, number] { + const inner = bitLen(index ^ (size - 1)); + const border = onesCount(Math.floor(index / 2 ** inner)); + return [inner, border]; +} + +function chainInner(seed: Buffer, proof: Buffer[], index: number): Buffer { + let acc = seed; + for (let i = 0; i < proof.length; i++) { + acc = (Math.floor(index / 2 ** i) & 1) === 0 ? hashChildren(acc, proof[i]) : hashChildren(proof[i], acc); + } + return acc; +} + +function chainInnerRight(seed: Buffer, proof: Buffer[], index: number): Buffer { + let acc = seed; + for (let i = 0; i < proof.length; i++) { + if ((Math.floor(index / 2 ** i) & 1) === 1) acc = hashChildren(proof[i], acc); + } + return acc; +} + +function chainBorderRight(seed: Buffer, proof: Buffer[]): Buffer { + let acc = seed; + for (const h of proof) acc = hashChildren(h, acc); + return acc; +} + +/** Reconstruct the size-`treeSize` root implied by an inclusion proof. */ +export function rootFromInclusionProof( + leafHash: Buffer | string, + index: number, + treeSize: number, + proof: Array, +): Buffer { + if (index >= treeSize) throw new Error("rootFromInclusionProof: index >= treeSize"); + const path = proof.map(toBuf); + const [inner, border] = decompose(index, treeSize); + if (path.length !== inner + border) { + throw new Error(`rootFromInclusionProof: wrong proof size ${path.length}, want ${inner + border}`); + } + let res = chainInner(toBuf(leafHash), path.slice(0, inner), index); + res = chainBorderRight(res, path.slice(inner)); + return res; +} + +export function verifyInclusionProof( + leafHash: Buffer | string, + index: number, + treeSize: number, + proof: Array, + root: Buffer | string, +): boolean { + try { + return rootFromInclusionProof(leafHash, index, treeSize, proof).equals(toBuf(root)); + } catch { + return false; + } +} + +export function verifyConsistencyProof( + m: number, + n: number, + proof: Array, + rootM: Buffer | string, + rootN: Buffer | string, +): boolean { + try { + const r1 = toBuf(rootM); + const r2 = toBuf(rootN); + const path = proof.map(toBuf); + + if (m > n) return false; + if (m === n) return path.length === 0 && r1.equals(r2); + if (m === 0) return path.length === 0; + if (path.length === 0) return false; + + let [inner, border] = decompose(m - 1, n); + const shift = trailingZeros(m); + inner -= shift; + + let seed: Buffer; + let start: number; + if (m === 1 << shift) { + seed = r1; + start = 0; + } else { + seed = path[0]; + start = 1; + } + if (path.length !== start + inner + border) return false; + + const rest = path.slice(start); + const mask = Math.floor((m - 1) / 2 ** shift); + + let h1 = chainInnerRight(seed, rest.slice(0, inner), mask); + h1 = chainBorderRight(h1, rest.slice(inner)); + if (!h1.equals(r1)) return false; + + let h2 = chainInner(seed, rest.slice(0, inner), mask); + h2 = chainBorderRight(h2, rest.slice(inner)); + return h2.equals(r2); + } catch { + return false; + } +} diff --git a/backend/tests/services/evidenceBundle.pure.test.ts b/backend/tests/services/evidenceBundle.pure.test.ts new file mode 100644 index 00000000..e608e9ef --- /dev/null +++ b/backend/tests/services/evidenceBundle.pure.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from "vitest"; +import { + buildBundleCore, + bundleLogEntryData, + discloseSubset, + logLeafHashHex, + signedTreeHeadMessage, + signEvidenceRoot, + verifyEvidenceBundle, + type EvidenceBundleDocument, + type SignerMetadata, +} from "../../src/services/transparencyLog/evidenceBundle.js"; +import { + inclusionProofFromLeafHashes, + rootHexFromLeafHashes, +} from "../../src/services/transparencyLog/merkle.js"; +import { generateRawKeyPair, signRaw } from "../../src/services/transparencyLog/ed25519.js"; + +// ── fixtures ────────────────────────────────────────────────────────────── + +const signerKp = generateRawKeyPair(); +const logKp = generateRawKeyPair(); + +const signer: SignerMetadata = { + keyId: "ebk_test_signer", + algorithm: "ed25519", + publicKeyHex: signerKp.publicKeyHex, + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: null, + rotatesKeyId: null, + logEntryIndex: 0, +}; + +const keyRegistrationEntry = { + type: "key_registration" as const, + keyId: signer.keyId, + algorithm: "ed25519" as const, + publicKeyHex: signer.publicKeyHex, + validFrom: signer.validFrom, + rotatesKeyId: null, +}; + +function buildSignedBundle(overrides: Partial[0]> = {}) { + const built = buildBundleCore({ + bundleId: "eb_test", + subject: { type: "compliance_report", id: "rep_123", reportType: "bridge_activity" }, + createdAt: "2026-06-01T00:00:00.000Z", + sections: [ + { sectionId: "raw_observations", value: [{ ledger: 55_000_001, amount: "100.0" }] }, + { sectionId: "chain_evidence", value: { txHash: "0xabc", confirmations: 64 } }, + { sectionId: "reserve_attestation", value: { issuer: "circle", balance: "1000000" } }, + ], + finalityMetadata: { + chain: "stellar", + observedLedger: 55_000_010, + finalizedLedger: 55_000_005, + confirmations: 5, + finalityThreshold: 1, + finalized: true, + observedAt: "2026-06-01T00:00:00.000Z", + }, + decoderVersions: { "stellar-xdr": "21.2.0", "evm-abi": "6.13.4" }, + codeVersion: { gitCommit: "5d30d8d" }, + configVersion: { hash: "cfg_deadbeef", version: 42 }, + queryParameters: { periodStart: "2026-05-01", periodEnd: "2026-06-01", assets: ["USDC", "EURC"] }, + derivedOutputs: [{ outputId: "report_pdf", mediaType: "application/pdf", value: { pages: 12, total: "5000000" } }], + signer, + ...overrides, + }); + + const signature = signEvidenceRoot(signerKp.privateKeyHex, built.evidenceRoot); + const bundleEntry = bundleLogEntryData(built.core, built.evidenceRoot); + + const leaves = [keyRegistrationEntry, bundleEntry].map((d) => logLeafHashHex(d)); + const treeSize = leaves.length; + const rootHash = rootHexFromLeafHashes(leaves); + const timestamp = "2026-06-01T00:00:01.000Z"; + const sthSignature = signRaw(logKp.privateKeyHex, signedTreeHeadMessage({ treeSize, rootHash, timestamp })); + + const doc: EvidenceBundleDocument = { + core: built.core, + evidenceRoot: built.evidenceRoot, + signature, + disclosedSections: built.disclosedSections, + redactedSectionIds: [], + disclosedOutputs: built.disclosedOutputs, + transparency: { + logId: "primary", + entryIndex: 1, + entryData: bundleEntry, + treeSize, + rootHash, + inclusionProof: inclusionProofFromLeafHashes(1, leaves), + signedTreeHead: { + treeSize, + rootHash, + timestamp, + logPublicKeyHex: logKp.publicKeyHex, + signature: sthSignature, + }, + keyRegistration: { + entryIndex: 0, + entryData: keyRegistrationEntry, + inclusionProof: inclusionProofFromLeafHashes(0, leaves), + }, + }, + }; + + return { built, doc }; +} + +// ── tests ───────────────────────────────────────────────────────────────── + +describe("evidence bundle — offline verification", () => { + it("validates a well-formed bundle with no database", () => { + const { doc } = buildSignedBundle(); + const result = verifyEvidenceBundle(doc, { requireTransparency: true }); + expect(result.valid).toBe(true); + expect(result.checks.find((c) => c.name === "signature")?.ok).toBe(true); + expect(result.checks.find((c) => c.name === "transparency_inclusion")?.ok).toBe(true); + expect(result.checks.find((c) => c.name === "signed_tree_head")?.ok).toBe(true); + expect(result.checks.find((c) => c.name === "key_registration")?.ok).toBe(true); + }); + + it("rejects a tampered input observation", () => { + const { doc } = buildSignedBundle(); + doc.disclosedSections[0].value = [{ ledger: 55_000_001, amount: "999999.0" }]; + const result = verifyEvidenceBundle(doc); + expect(result.valid).toBe(false); + expect(result.checks.find((c) => c.name === "section_hash")?.ok).toBe(false); + }); + + it("rejects a tampered derived output", () => { + const { doc } = buildSignedBundle(); + doc.disclosedOutputs[0].value = { pages: 12, total: "9999999" }; + const result = verifyEvidenceBundle(doc); + expect(result.valid).toBe(false); + expect(result.checks.find((c) => c.name === "output_hash")?.ok).toBe(false); + }); + + it("rejects a mutated core field (evidence root / signature break)", () => { + const { doc } = buildSignedBundle(); + doc.core.queryParameters.assets = ["USDC", "EURC", "PYUSD"]; + const result = verifyEvidenceBundle(doc); + expect(result.valid).toBe(false); + expect(result.checks.find((c) => c.name === "evidence_root")?.ok).toBe(false); + expect(result.checks.find((c) => c.name === "signature")?.ok).toBe(false); + }); + + it("rejects a forged signature from the wrong key", () => { + const { built } = buildSignedBundle(); + const attacker = generateRawKeyPair(); + const forged = signEvidenceRoot(attacker.privateKeyHex, built.evidenceRoot); + const result = verifyEvidenceBundle({ + core: built.core, + evidenceRoot: built.evidenceRoot, + signature: forged, + disclosedSections: built.disclosedSections, + redactedSectionIds: [], + disclosedOutputs: built.disclosedOutputs, + transparency: null, + }); + expect(result.checks.find((c) => c.name === "signature")?.ok).toBe(false); + }); + + it("preserves proof validity under partial disclosure", () => { + const { doc } = buildSignedBundle(); + const redacted = discloseSubset(doc, ["chain_evidence"]); + expect(redacted.disclosedSections.map((s) => s.sectionId)).toEqual(["chain_evidence"]); + expect(redacted.redactedSectionIds.sort()).toEqual(["raw_observations", "reserve_attestation"]); + + const result = verifyEvidenceBundle(redacted, { requireTransparency: true }); + expect(result.valid).toBe(true); + expect(result.checks.find((c) => c.name === "inputs_root")?.ok).toBe(true); + expect(result.checks.find((c) => c.name === "evidence_root")?.ok).toBe(true); + }); + + it("still validates a fully redacted bundle (commitments only)", () => { + const { doc } = buildSignedBundle(); + const result = verifyEvidenceBundle(discloseSubset(doc, []), { requireTransparency: true }); + expect(result.valid).toBe(true); + }); + + it("detects a redacted section whose commitment was swapped", () => { + const { doc } = buildSignedBundle(); + const redacted = discloseSubset(doc, ["chain_evidence"]); + redacted.core.sectionCommitments[0].contentHash = "0".repeat(64); + const result = verifyEvidenceBundle(redacted); + expect(result.valid).toBe(false); + // inputsRoot no longer matches the signed core. + expect(result.checks.find((c) => c.name === "evidence_root")?.ok).toBe(false); + }); + + it("flags a signer key used outside its validity window", () => { + const { doc } = buildSignedBundle({ + signer: { ...signer, validUntil: "2026-03-01T00:00:00.000Z" }, + }); + const result = verifyEvidenceBundle(doc); + expect(result.checks.find((c) => c.name === "signer_validity")?.ok).toBe(false); + }); + + it("represents key rotation in the signer metadata and registration entry", () => { + const rotatedSigner: SignerMetadata = { + ...signer, + keyId: "ebk_test_signer_v2", + rotatesKeyId: "ebk_test_signer", + logEntryIndex: 0, + }; + const built = buildBundleCore({ + bundleId: "eb_rot", + subject: { type: "export", id: "exp_9" }, + createdAt: "2026-07-01T00:00:00.000Z", + sections: [{ sectionId: "s1", value: { a: 1 } }], + signer: rotatedSigner, + }); + expect(built.core.signer.rotatesKeyId).toBe("ebk_test_signer"); + + const signature = signEvidenceRoot(signerKp.privateKeyHex, built.evidenceRoot); + const krEntry = { + type: "key_registration" as const, + keyId: rotatedSigner.keyId, + algorithm: "ed25519" as const, + publicKeyHex: rotatedSigner.publicKeyHex, + validFrom: rotatedSigner.validFrom, + rotatesKeyId: "ebk_test_signer", + }; + const bundleEntry = bundleLogEntryData(built.core, built.evidenceRoot); + const leaves = [krEntry, bundleEntry].map((d) => logLeafHashHex(d)); + const rootHash = rootHexFromLeafHashes(leaves); + const timestamp = "2026-07-01T00:00:01.000Z"; + + const doc: EvidenceBundleDocument = { + core: built.core, + evidenceRoot: built.evidenceRoot, + signature, + disclosedSections: built.disclosedSections, + redactedSectionIds: [], + disclosedOutputs: [], + transparency: { + logId: "primary", + entryIndex: 1, + entryData: bundleEntry, + treeSize: 2, + rootHash, + inclusionProof: inclusionProofFromLeafHashes(1, leaves), + signedTreeHead: { + treeSize: 2, + rootHash, + timestamp, + logPublicKeyHex: logKp.publicKeyHex, + signature: signRaw(logKp.privateKeyHex, signedTreeHeadMessage({ treeSize: 2, rootHash, timestamp })), + }, + keyRegistration: { + entryIndex: 0, + entryData: krEntry, + inclusionProof: inclusionProofFromLeafHashes(0, leaves), + }, + }, + }; + + const result = verifyEvidenceBundle(doc, { requireTransparency: true }); + expect(result.valid).toBe(true); + expect(result.checks.find((c) => c.name === "key_registration")?.ok).toBe(true); + }); + + it("rejects a bundle whose transparency entry points at a different evidence root", () => { + const { doc } = buildSignedBundle(); + doc.transparency!.entryData.evidenceRoot = "0".repeat(64); + const result = verifyEvidenceBundle(doc, { requireTransparency: true }); + expect(result.valid).toBe(false); + expect(result.checks.find((c) => c.name === "transparency_entry")?.ok).toBe(false); + }); +}); diff --git a/backend/tests/services/transparencyLogMerkle.test.ts b/backend/tests/services/transparencyLogMerkle.test.ts new file mode 100644 index 00000000..c3c954e5 --- /dev/null +++ b/backend/tests/services/transparencyLogMerkle.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { createHash } from "crypto"; +import { + hashLeaf, + merkleTreeHash, + merkleRootHex, + inclusionProof, + consistencyProof, + rootFromLeafHashes, + inclusionProofFromLeafHashes, + consistencyProofFromLeafHashes, + verifyInclusionProof, + verifyConsistencyProof, +} from "../../src/services/transparencyLog/merkle.js"; + +// RFC 6962 §2.1 test leaves. +const LEAVES = [ + "", + "00", + "10", + "2021", + "3031", + "40414243", + "5051525354555657", + "606162636465666768696a6b6c6d6e6f", +].map((h) => Buffer.from(h, "hex")); + +describe("RFC 6962 Merkle tree", () => { + it("matches known-answer roots", () => { + // SHA-256("") — the empty tree. + expect(merkleRootHex([])).toBe(createHash("sha256").update(Buffer.alloc(0)).digest("hex")); + // SHA-256(0x00) — a single empty leaf. + expect(merkleRootHex(LEAVES.slice(0, 1))).toBe( + "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d", + ); + // Documented CT test vector for the 8-leaf tree. + expect(merkleRootHex(LEAVES)).toBe( + "5dc9da79a70659a9ad559cb701ded9a2ab9d823aad2f4960cfe370eff4604328", + ); + }); + + it("is deterministic and order-sensitive", () => { + expect(merkleRootHex(LEAVES.slice(0, 4))).toBe(merkleRootHex(LEAVES.slice(0, 4))); + const swapped = [LEAVES[1], LEAVES[0], ...LEAVES.slice(2, 4)]; + expect(merkleRootHex(swapped)).not.toBe(merkleRootHex(LEAVES.slice(0, 4))); + }); + + it("rootFromLeafHashes equals merkleTreeHash over pre-hashed leaves", () => { + for (let n = 0; n <= LEAVES.length; n++) { + const subset = LEAVES.slice(0, n); + expect(rootFromLeafHashes(subset.map(hashLeaf)).toString("hex")).toBe(merkleTreeHash(subset).toString("hex")); + } + }); + + it("produces and verifies inclusion proofs for every leaf in every tree size", () => { + for (let n = 1; n <= LEAVES.length; n++) { + const subset = LEAVES.slice(0, n); + const root = merkleRootHex(subset); + for (let i = 0; i < n; i++) { + const proof = inclusionProof(i, subset); + expect(verifyInclusionProof(hashLeaf(subset[i]).toString("hex"), i, n, proof, root)).toBe(true); + // Wrong leaf hash must fail. + expect(verifyInclusionProof(hashLeaf(Buffer.from("deadbeef", "hex")).toString("hex"), i, n, proof, root)).toBe(false); + // Tampered proof element must fail. + if (proof.length > 0) { + const bad = [...proof]; + bad[0] = createHash("sha256").update(Buffer.from(bad[0], "hex")).digest("hex"); + expect(verifyInclusionProof(hashLeaf(subset[i]).toString("hex"), i, n, bad, root)).toBe(false); + } + } + } + }); + + it("inclusionProofFromLeafHashes agrees with the raw-data variant", () => { + const subset = LEAVES.slice(0, 7); + const leafHashes = subset.map(hashLeaf); + for (let i = 0; i < subset.length; i++) { + expect(inclusionProofFromLeafHashes(i, leafHashes)).toEqual(inclusionProof(i, subset)); + } + }); + + it("produces and verifies consistency proofs for every m <= n", () => { + for (let n = 1; n <= LEAVES.length; n++) { + const big = LEAVES.slice(0, n); + const rootN = merkleRootHex(big); + for (let m = 1; m <= n; m++) { + const rootM = merkleRootHex(LEAVES.slice(0, m)); + const proof = consistencyProof(m, big); + expect(verifyConsistencyProof(m, n, proof, rootM, rootN)).toBe(true); + // A forged older root must fail. + expect(verifyConsistencyProof(m, n, proof, rootN, rootN)).toBe(m === n); + } + } + }); + + it("consistencyProofFromLeafHashes agrees with the raw-data variant", () => { + const big = LEAVES.slice(0, 8); + const leafHashes = big.map(hashLeaf); + for (let m = 1; m <= 8; m++) { + expect(consistencyProofFromLeafHashes(m, leafHashes)).toEqual(consistencyProof(m, big)); + } + }); + + it("detects an inconsistent (non-append-only) log", () => { + const original = LEAVES.slice(0, 5); + const rewritten = [...LEAVES.slice(0, 4), Buffer.from("ffff", "hex"), ...LEAVES.slice(5, 7)]; + const proof = consistencyProof(5, [...original, ...LEAVES.slice(5, 7)]); + // rewritten history at size 5 no longer matches. + expect(verifyConsistencyProof(5, 7, proof, merkleRootHex(rewritten.slice(0, 5)), merkleRootHex(rewritten))).toBe(false); + }); +}); diff --git a/docs/evidence-bundles.md b/docs/evidence-bundles.md new file mode 100644 index 00000000..ed558e4d --- /dev/null +++ b/docs/evidence-bundles.md @@ -0,0 +1,163 @@ +# Signed Evidence Bundles & Append-Only Transparency Log + +> Issue #1019 — Evidence, reports, and compliance. + +A **signed evidence bundle** is a portable, independently verifiable proof of +*exactly* which raw observations, code/config versions, decoder versions, query +parameters and chain-finality metadata produced a report or export. Every bundle +commitment is also appended to an **append-only transparency log** (an RFC 6962 +Merkle tree) that publishes inclusion and consistency proofs, so an auditor can +show a bundle existed at a point in time and that the log was never rewritten. + +Nothing in verification requires trusting the Bridge Watch database: the bundle +document carries the signer's public key, the log's signed tree head, and all +Merkle proof material. + +## Components + +| Piece | Where | +| --- | --- | +| Pure canonicalization / Merkle / Ed25519 / bundle logic | `backend/src/services/transparencyLog/*` | +| Database service (keys, log append, proofs, disclosure) | `backend/src/services/evidenceBundle.service.ts` | +| REST API (`/api/v1/evidence`) | `backend/src/api/routes/evidenceBundle.routes.ts` | +| Schema | `backend/src/database/migrations/20260829100000_signed_evidence_bundles.ts` | +| Standalone offline verifier (stdlib only) | `backend/scripts/verify-evidence-bundle.mjs` | + +## Bundle document + +```jsonc +{ + "core": { // everything below is canonicalized + hashed => evidenceRoot + "bundleId": "eb_…", + "bundleFormatVersion": "1.0", + "subject": { "type": "compliance_report", "id": "rep_123", "reportType": "bridge_activity" }, + "createdAt": "2026-06-01T00:00:00.000Z", + "inputsRoot": "", + "sectionCommitments": [ + { "sectionId": "raw_observations", "mediaType": "application/json", + "label": "raw_observations", "contentHash": "sha256(salt || canonical(value))" } + ], + "finalityMetadata": { "chain": "stellar", "observedLedger": 55000010, + "finalizedLedger": 55000005, "confirmations": 5, + "finalityThreshold": 1, "finalized": true, "observedAt": "…" }, + "decoderVersions": { "stellar-xdr": "21.2.0", "evm-abi": "6.13.4" }, + "codeVersion": { "gitCommit": "5d30d8d" }, + "configVersion": { "hash": "cfg_…", "version": 42 }, + "queryParameters": { "periodStart": "2026-05-01", "periodEnd": "2026-06-01" }, + "derivedOutputs": [ { "outputId": "report_pdf", "mediaType": "application/pdf", + "label": "report_pdf", "outputHash": "sha256(canonical(value))" } ], + "signer": { "keyId": "ebk_…", "algorithm": "ed25519", "publicKeyHex": "…", + "validFrom": "…", "validUntil": null, "rotatesKeyId": null, "logEntryIndex": 0 } + }, + "evidenceRoot": "sha256(canonical(core))", + "signature": "ed25519(privateKey, evidenceRoot)", // hex + + "disclosedSections": [ { "sectionId": "…", "saltHex": "…", "value": … } ], + "redactedSectionIds": [ "…" ], + "disclosedOutputs": [ { "outputId": "…", "value": … } ], + + "transparency": { + "entryIndex": 1, + "entryData": { "type": "evidence_bundle", "bundleId": "eb_…", + "evidenceRoot": "…", "signerKeyId": "ebk_…" }, + "treeSize": 2, + "rootHash": "…", + "inclusionProof": [ "…" ], + "signedTreeHead": { "treeSize": 2, "rootHash": "…", "timestamp": "…", + "logPublicKeyHex": "…", "signature": "…" }, + "keyRegistration": { "entryIndex": 0, "entryData": { "type": "key_registration", … }, + "inclusionProof": [ "…" ] }, + "keyRevocation": { … } // present only if the signer key was later revoked + } +} +``` + +### Hash / commitment rules + +* **Canonical JSON** — RFC 8785 subset: object keys sorted by UTF-16 code unit, + no whitespace, `undefined` dropped, non-finite numbers rejected. +* `contentHash` of an input section = `SHA-256(salt || canonicalJSON(value))`. + The salt blinds low-entropy values that may be redacted. +* `inputsRoot` = RFC 6962 Merkle Tree Hash over + `canonicalJSON({ sectionId, mediaType, contentHash })` leaves. +* `evidenceRoot` = `SHA-256(canonicalJSON(core))`. This is what gets **signed** + and what gets **logged**. +* Transparency-log leaf hash = `SHA-256(0x00 || canonicalJSON(entryData))`; + inner node = `SHA-256(0x01 || left || right)`. + +Any change to any disclosed input, redacted commitment, derived output, decoder +version, query parameter or finality field changes `evidenceRoot`, which breaks +both the signature and the transparency-log inclusion proof. + +## Partial disclosure + +`GET /api/v1/evidence/bundles/:id/disclose?sections=chain_evidence&outputs=report_pdf` +returns the same `core`, `evidenceRoot` and `signature`, but only the requested +section values (with their salts). Redacted sections appear as +`redactedSectionIds` — the verifier still recomputes `inputsRoot` from the +commitments, so proof validity is preserved. A fully redacted bundle +(`?sections=`) still verifies down to the commitment level. + +## Key rotation & revocation + +Signer keys are Ed25519. Their lifecycle is itself recorded in the transparency +log: + +* `key_registration` — appended when a signer key is first provisioned; + `rotatesKeyId` links to the predecessor. +* `key_revocation` — appended by `POST /log/keys/:keyId/revoke`; the verifier + flags any bundle signed at/after the revocation time. + +`POST /api/v1/evidence/log/keys/rotate` supersedes the active signer (sets +`valid_until`, `superseded_by_key_id`) and registers a fresh key. Bundles pin the +`signer` metadata into the signed core, and the offline verifier checks +`createdAt` against `[validFrom, validUntil]`. + +The log's **signed tree head** is signed with a separate `log`-purpose key so +tree-head trust and bundle-authorship trust are independent. + +## REST endpoints (`/api/v1/evidence`) + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/bundles` | create a signed bundle for a report/export | +| `GET` | `/bundles` | list bundles (`?subjectType=&subjectId=`) | +| `GET` | `/bundles/:bundleId` | full bundle document | +| `GET` | `/bundles/:bundleId/disclose` | partial-disclosure view | +| `GET` | `/bundles/:bundleId/verify` | server-side offline verification report | +| `POST` | `/bundles/verify` | stateless verification of a supplied document | +| `GET` | `/log` | transparency-log entries | +| `GET` | `/log/checkpoint` | latest signed tree head | +| `GET` | `/log/proof/inclusion` | `?logIndex=&treeSize=` | +| `GET` | `/log/proof/consistency` | `?first=&second=` | +| `GET` | `/log/keys` | signer keys with rotation / revocation | +| `POST` | `/log/keys/rotate` | rotate the active bundle signer | +| `POST` | `/log/keys/:keyId/revoke` | revoke a signer key | + +Report and export responses/metadata link to a bundle via its `evidenceRoot` +(and `bundleId`); the PDF footer / REST payload carries the same root so a reader +can fetch `/api/v1/evidence/bundles/:bundleId` and verify. + +## Verifying offline + +```sh +# From a saved document +curl -s "$API/api/v1/evidence/bundles/eb_abc123" > bundle.json +node backend/scripts/verify-evidence-bundle.mjs bundle.json + +# Or straight from the pipe +curl -s "$API/api/v1/evidence/bundles/eb_abc123" | node backend/scripts/verify-evidence-bundle.mjs - +``` + +The script depends only on the Node standard library and prints a PASS/FAIL line +per check (`section_hash`, `inputs_root`, `evidence_root`, `signature`, +`signer_validity`, `transparency_inclusion`, `signed_tree_head`, +`key_registration`, …). Exit code `0` = valid, `1` = invalid. + +To check the log has not been rewritten between two observations, compare their +tree heads with a consistency proof: + +```sh +curl -s "$API/api/v1/evidence/log/proof/consistency?first=12&second=487" +# { …, "valid": true } +```