From fed8ba67db725c1150f678ab050b4efed68102f5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:05:40 -0700 Subject: [PATCH 1/2] feat(ledger): Rekor v2 hashedrekord anchoring backend (#9272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth sub-issue of #9267. Primary anchoring mechanism per the mechanism research: hashedrekord is exactly built for a plain hash + signature — Rekor never sees the anchor payload itself. No Fulcio, no OIDC, no new dependency (ECDSA P-256/SHA-256 is native WebCrypto, the same keypair #9270 already produces). Shard URL is env-configurable with a documented fallback, never hardcoded, per the research's explicit "shards roll annually" warning. Every path — non-2xx response, an unparseable response body, a network exception — records status:'failed' via #9271's persistence rather than throwing past the caller, so #9273's git backend still gets attempted even if this one fails. Caught and fixed a real bug during testing: this module was pre-stringifying its own error before handing it to recordLedgerAnchorAttempt, which then ran errorMessage() a SECOND time on what was now a plain string — and errorMessage only extracts .message from actual Error instances, so every failure path collapsed to the generic "unknown error" fallback regardless of what actually went wrong. Fixed by passing the raw unknown error through and teaching persistence to handle both an already-built string and a genuine Error, so the real diagnostic (a 429, a shape mismatch, "network down") reaches the public listing instead of being silently discarded. Storing the full Rekor TransparencyLogEntry (inclusion proof + signed checkpoint) for fully offline verification is deliberately deferred — online verification via rekor-cli against the stored shard URL + uuid works completely without it today. --- src/env.d.ts | 6 + src/review/ledger-anchor-persistence.ts | 6 +- src/review/ledger-anchor-rekor.ts | 170 ++++++++++++++++++++++++ test/unit/ledger-anchor-rekor.test.ts | 129 ++++++++++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/review/ledger-anchor-rekor.ts create mode 100644 test/unit/ledger-anchor-rekor.test.ts diff --git a/src/env.d.ts b/src/env.d.ts index 934ba5c21..18d7c2faa 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -615,6 +615,12 @@ declare global { * pre-#9267 posture, so an unprovisioned key degrades honestly instead of failing. See * review/ledger-anchor.ts. */ LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY?: string; + /** External ledger anchoring (#9272, epic #9267): the Rekor v2 shard base URL to submit anchors to. + * Rekor shards ANNUALLY (log2025-1, log2026-1, ...) and the project's own guidance is explicit: never + * hardcode a log URL. Defaults to the current shard as of when this was written if unset — an operator + * updates this var at the next rotation rather than needing a code change. See + * review/ledger-anchor-rekor.ts. */ + LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL?: string; /** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the * /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo. * Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */ diff --git a/src/review/ledger-anchor-persistence.ts b/src/review/ledger-anchor-persistence.ts index a21820253..044ed53b2 100644 --- a/src/review/ledger-anchor-persistence.ts +++ b/src/review/ledger-anchor-persistence.ts @@ -51,7 +51,11 @@ export async function recordLedgerAnchorAttempt(env: Env, attempt: LedgerAnchorA const payloadJson = JSON.stringify(attempt.payload); const backendRef = attempt.status === "ok" ? JSON.stringify(attempt.backendRef) : null; const proofR2Key = attempt.status === "ok" ? attempt.proofR2Key : null; - const error = attempt.status === "failed" ? errorMessage(attempt.error).slice(0, 500) : null; + // A backend's own error is `unknown`: it may already be a hand-built descriptive string (the common case -- + // "Rekor responded 429: ...") or a genuine thrown Error (a network exception) -- errorMessage() alone only + // recognizes the latter, collapsing an already-good string to its generic fallback. Use the string as-is; + // only fall back to errorMessage()'s Error-extraction for anything else. + const error = attempt.status === "failed" ? (typeof attempt.error === "string" ? attempt.error : errorMessage(attempt.error)).slice(0, 500) : null; await env.DB.prepare( `INSERT INTO decision_ledger_anchors diff --git a/src/review/ledger-anchor-rekor.ts b/src/review/ledger-anchor-rekor.ts new file mode 100644 index 000000000..6e6ae5f64 --- /dev/null +++ b/src/review/ledger-anchor-rekor.ts @@ -0,0 +1,170 @@ +// Rekor v2 hashedrekord anchoring backend (#9272, epic #9267). Primary anchoring mechanism per the mechanism +// research on #9267: a plain hash + signature is exactly what `hashedrekord` is for -- Rekor never sees the +// anchor payload itself, only a digest and a signature over it, plus the self-managed verifier public key +// from #9270. No Fulcio, no OIDC, no new dependency (ECDSA P-256/SHA-256 is native WebCrypto). Free, ~200 +// byte payload, 99.5% SLO. +import type { SignedLedgerAnchor } from "./ledger-anchor"; +import { anchorSigningInput } from "./ledger-anchor"; +import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence"; + +/** Rekor shards annually and the research is explicit: do not hardcode a log URL. Configurable via env, + * with a fallback to the current shard as of when this was written -- an operator updates the env var at + * the next rotation rather than this needing a code change. */ +const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2026-1.rekor.sigstore.dev"; + +/** The exact `hashedRekordRequestV002` body Rekor v2's `POST /api/v2/log/entries` accepts. Digest and + * signature are both base64 per the API; `keyDetails` names the algorithm so Rekor can verify without + * guessing. */ +export type HashedRekordRequestV002 = { + hashedRekordRequestV002: { + digest: string; + signature: { + content: string; + verifier: { + publicKey: { rawBytes: string }; + keyDetails: "PKIX_ECDSA_P256_SHA_256"; + }; + }; + }; +}; + +/** The subset of Rekor's `TransparencyLogEntry` response this module reads. The full response (including the + * inclusion proof and signed checkpoint) is what would let a verifier check inclusion fully OFFLINE without + * trusting Rekor's continued availability -- storing that blob is deliberately deferred past this PR (see + * this module's own header on `proofR2Key`), but the fields below are enough for ONLINE verification via + * `rekor-cli verify --uuid ... --artifact-hash ...` today. */ +export type RekorTransparencyLogEntryResponse = { + logIndex: number; + logId: { keyId: string }; + /** Rekor's own entry identifier, the `uuid` a verifier passes to `rekor-cli verify`. Present as the + * response object's own key in the v2 API (one entry per request), not a fixed field name. */ + uuid: string; +}; + +/** + * Build the exact request body Rekor v2 expects, from an already-signed anchor and the matching published + * public key. PURE and synchronous -- the digest itself needs one async hash, so this returns a Promise, but + * makes no network call. + */ +export async function buildHashedRekordRequest(signed: SignedLedgerAnchor, publicKeySpkiBase64: string): Promise { + const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(anchorSigningInput(signed.payload))); + const digest = base64Encode(new Uint8Array(digestBytes)); + return { + hashedRekordRequestV002: { + digest, + signature: { + content: signed.signature, + verifier: { + publicKey: { rawBytes: publicKeySpkiBase64 }, + keyDetails: "PKIX_ECDSA_P256_SHA_256", + }, + }, + }, + }; +} + +function base64Encode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** + * Parse the fields this module needs out of Rekor's raw JSON response. Rekor v2 nests the entry under a + * dynamic key (the submitted entry's own uuid) rather than a fixed field name -- this reads the first (and + * only, for a single-entry submission) value. Returns `null` for any response shape that doesn't match, + * rather than throwing, so a Rekor API change degrades to a recorded failure instead of an unhandled crash. + */ +export function parseRekorResponse(raw: unknown): RekorTransparencyLogEntryResponse | null { + if (typeof raw !== "object" || raw === null) return null; + const entries = Object.values(raw as Record); + const entry = entries[0]; + if (typeof entry !== "object" || entry === null) return null; + const candidate = entry as Record; + const logId = candidate["logId"]; + if ( + typeof candidate["logIndex"] !== "number" || + typeof candidate["uuid"] !== "string" || + typeof logId !== "object" || + logId === null || + typeof (logId as Record)["keyId"] !== "string" + ) { + return null; + } + return { logIndex: candidate["logIndex"], uuid: candidate["uuid"], logId: { keyId: (logId as Record)["keyId"] as string } }; +} + +/** + * Submit a signed anchor to Rekor v2 and record the outcome via #9271's persistence -- success or failure, + * always. Never throws: a network error, a non-2xx response, or an unparseable response body all become a + * `status: 'failed'` row with the error, matching this backend's own issue text ("must not throw past the + * caller", so #9273's git backend still gets attempted even if this one fails). + * + * `fetchImpl` is injectable so tests exercise this function's own logic against a scripted response, never a + * real network call to Rekor. + */ +export async function submitToRekor( + env: Env, + signed: SignedLedgerAnchor, + publicKeySpkiBase64: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const shardBaseUrl = env.LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL ?? DEFAULT_REKOR_SHARD_BASE_URL; + try { + const body = await buildHashedRekordRequest(signed, publicKeySpkiBase64); + // v2 batches submissions -- a short timeout would misread a slow-but-successful submission as failure. + const response = await fetchImpl(`${shardBaseUrl}/api/v2/log/entries`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) { + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "rekor", + status: "failed", + error: `Rekor responded ${response.status}: ${(await response.text()).slice(0, 200)}`, + }); + return; + } + const parsed = parseRekorResponse(await response.json()); + if (!parsed) { + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "rekor", + status: "failed", + error: "Rekor response did not match the expected TransparencyLogEntry shape", + }); + return; + } + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "rekor", + status: "ok", + backendRef: { shardBaseUrl, logIndex: parsed.logIndex, logIdKeyId: parsed.logId.keyId, uuid: parsed.uuid }, + // Deferred past this PR: storing the full TransparencyLogEntry (inclusion proof + signed checkpoint) in + // R2 for fully offline verification. Online verification (rekor-cli against shardBaseUrl + uuid) works + // fully without it today; the offline path is a documented enhancement, not a gap in this backend. + proofR2Key: null, + }); + } catch (error) { + // Pass the raw caught value through, not a pre-stringified one -- #9271's persistence layer is the single + // place that normalizes an unknown error into text (Error instance vs. anything else), so this backend + // and every other one feed it the same undecided shape rather than each reimplementing that choice. + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "rekor", + status: "failed", + error, + }); + } +} diff --git a/test/unit/ledger-anchor-rekor.test.ts b/test/unit/ledger-anchor-rekor.test.ts new file mode 100644 index 000000000..449640c2d --- /dev/null +++ b/test/unit/ledger-anchor-rekor.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { buildHashedRekordRequest, parseRekorResponse, submitToRekor } from "../../src/review/ledger-anchor-rekor"; +import { buildLedgerAnchorPayload, signLedgerAnchorPayload, type SignedLedgerAnchor } from "../../src/review/ledger-anchor"; +import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence"; + +// #9272 (epic #9267). fetch is ALWAYS injected -- never a real network call to Rekor. The property under +// test is that this module's own request/response/persistence logic is correct; Rekor's actual API is out of +// scope for a unit test and is exercised, if at all, by hand against the real service. + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +async function realSignedAnchor(): Promise<{ signed: SignedLedgerAnchor; publicKeySpki: string }> { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const pkcs8 = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer)); + const publicKeySpki = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer)); + const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${(pkcs8.match(/.{1,64}/g) ?? []).join("\n")}\n-----END PRIVATE KEY-----`; + const payload = buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"); + const signed = await signLedgerAnchorPayload(payload, privateKeyPem, "key1"); + return { signed, publicKeySpki }; +} + +const REKOR_RESPONSE = { "24296fb24b8ad77a": { logIndex: 42, uuid: "24296fb24b8ad77a", logId: { keyId: "c2iga0d1" } } }; + +describe("buildHashedRekordRequest (#9272)", () => { + it("builds the exact hashedRekordRequestV002 shape Rekor v2 expects", async () => { + const { signed, publicKeySpki } = await realSignedAnchor(); + const request = await buildHashedRekordRequest(signed, publicKeySpki); + + expect(request.hashedRekordRequestV002.signature.content).toBe(signed.signature); + expect(request.hashedRekordRequestV002.signature.verifier.publicKey.rawBytes).toBe(publicKeySpki); + expect(request.hashedRekordRequestV002.signature.verifier.keyDetails).toBe("PKIX_ECDSA_P256_SHA_256"); + expect(request.hashedRekordRequestV002.digest).not.toBe(""); + }); + + it("is deterministic: the same signed anchor always produces the same digest", async () => { + const { signed, publicKeySpki } = await realSignedAnchor(); + const a = await buildHashedRekordRequest(signed, publicKeySpki); + const b = await buildHashedRekordRequest(signed, publicKeySpki); + expect(a.hashedRekordRequestV002.digest).toBe(b.hashedRekordRequestV002.digest); + }); +}); + +describe("parseRekorResponse", () => { + it("parses a real-shaped Rekor v2 response, reading the entry under its dynamic uuid key", () => { + expect(parseRekorResponse(REKOR_RESPONSE)).toEqual({ logIndex: 42, uuid: "24296fb24b8ad77a", logId: { keyId: "c2iga0d1" } }); + }); + + it("returns null (never throws) for any response shape it does not recognize", () => { + expect(parseRekorResponse(null)).toBeNull(); + expect(parseRekorResponse("a string")).toBeNull(); + expect(parseRekorResponse({})).toBeNull(); + expect(parseRekorResponse({ x: {} })).toBeNull(); + expect(parseRekorResponse({ x: { logIndex: "not-a-number", uuid: "u", logId: { keyId: "k" } } })).toBeNull(); + expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u", logId: null } })).toBeNull(); + expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u" } })).toBeNull(); + expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u", logId: { keyId: 7 } } })).toBeNull(); + }); +}); + +describe("submitToRekor (#9272)", () => { + it("records status:'ok' with the FULL resolvable backend_ref on a successful submission", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL: "https://log2026-1.rekor.sigstore.dev" }); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 })); + + await submitToRekor(env, signed, publicKeySpki, fetchMock); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors).toHaveLength(1); + expect(anchors[0]).toMatchObject({ + seq: 1, + backend: "rekor", + status: "ok", + backendRef: { shardBaseUrl: "https://log2026-1.rekor.sigstore.dev", logIndex: 42, logIdKeyId: "c2iga0d1", uuid: "24296fb24b8ad77a" }, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://log2026-1.rekor.sigstore.dev/api/v2/log/entries", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("uses the default shard URL when unconfigured", async () => { + const env = createTestEnv(); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 })); + await submitToRekor(env, signed, publicKeySpki, fetchMock); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("rekor.sigstore.dev"), expect.anything()); + }); + + it("records status:'failed' on a non-2xx response, and does NOT throw", async () => { + const env = createTestEnv(); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockResolvedValue(new Response("rate limited", { status: 429 })); + + await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined(); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ status: "failed", backend: "rekor" }); + expect(anchors[0]?.error).toContain("429"); + }); + + it("records status:'failed' when the response body does not parse as a TransparencyLogEntry", async () => { + const env = createTestEnv(); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ unexpected: "shape" }), { status: 201 })); + + await submitToRekor(env, signed, publicKeySpki, fetchMock); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]?.status).toBe("failed"); + expect(anchors[0]?.error).toContain("expected TransparencyLogEntry shape"); + }); + + it("records status:'failed' (not a thrown error past the caller) on a network exception", async () => { + const env = createTestEnv(); + const { signed, publicKeySpki } = await realSignedAnchor(); + const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); + + await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined(); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ status: "failed", error: "network down" }); + }); +}); From 1c7a6b8d3c2f76e54fb3fe4dbef1847bf14b02ca Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:44:13 -0700 Subject: [PATCH 2/2] feat(ledger): git-commit anchoring backend, cross-verified via GH Archive (#9273) (#9403) Fifth sub-issue of #9267. Secondary, complementary to Rekor: appends one JSONL line per anchor to a public repo via the GitHub Contents API and the existing makeInstallationOctokit chokepoint -- never a direct fetch to the GitHub API, matching every other GitHub write in this engine. Alone this is weaker than Rekor (GitHub is a trusted third party, force-push rewrites it). It becomes genuinely strong combined with mirrors nobody at LoopOver controls: GH Archive's hourly PushEvent export and Software Heritage's on-demand archival. A rewrite becomes independently detectable by checking an archive this repo doesn't control -- documented as a runnable cross-mirror verification procedure in the module's own header. Commits the identical canonicalized payload + signature Rekor anchors, so the two backends commit to the same fact, never a reshaped copy. Read-modify- write via the file's own sha as a compare-and-swap guard; never throws past the caller (missing repo, auth failure, rate limit, a raced sha all record status:'failed' via #9271's persistence, same posture as the Rekor backend). Also fixes a real bug found while building this: ledger-anchor.ts's re-export statement only carried sha256Hex, dropping canonicalJson entirely -- any consumer importing it (this backend needs it to commit the same payload Rekor anchors) got undefined. Applied at the actual origin (#9270/PR #9392) and rebased through the whole stack, not patched over downstream. --- src/review/ledger-anchor-git.ts | 116 ++++++++++++++++++++++ src/review/ledger-anchor.ts | 1 - test/unit/ledger-anchor-git.test.ts | 146 ++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/review/ledger-anchor-git.ts create mode 100644 test/unit/ledger-anchor-git.test.ts diff --git a/src/review/ledger-anchor-git.ts b/src/review/ledger-anchor-git.ts new file mode 100644 index 000000000..458cff2f3 --- /dev/null +++ b/src/review/ledger-anchor-git.ts @@ -0,0 +1,116 @@ +// Git-commit anchoring backend (#9273, epic #9267). Secondary, complementary to Rekor (#9272): a commit +// appended to `anchors.jsonl` in a public repo, via the GitHub Contents API and the SAME installation-token +// chokepoint (makeInstallationOctokit) every other GitHub write in this engine goes through -- never a +// direct fetch to the GitHub API. +// +// Alone this is weaker than Rekor: GitHub is a trusted third party, and `git push --force` rewrites it. It +// becomes genuinely strong combined with mirrors nobody at LoopOver controls -- GH Archive's hourly +// `PushEvent` export and Software Heritage's on-demand "Save Code Now" archival -- which is why this backend +// exists as a SECOND, independent anchor for the same checkpoint rather than a replacement for Rekor. +// +// Cross-mirror verification, for a skeptic who does not want to trust this repo's own git history alone: +// 1. `git clone` the anchors repo and `git log --oneline -- anchors.jsonl` to find the commit for a seq. +// 2. Cross-check that push actually happened when claimed, from an archive LoopOver does not control: +// curl -s "https://data.gharchive.org/YYYY-MM-DD-HH.json.gz" | gunzip \ +// | jq 'select(.type=="PushEvent" and .repo.name=="/")' +// A commit present in the anchors repo but ABSENT from that hour's GH Archive export (once the day +// finishes being written) is exactly the signal a rewrite would leave behind. +import { githubErrorStatus } from "../github/app"; +import { canonicalJson, type SignedLedgerAnchor } from "./ledger-anchor"; +import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence"; + +/** Minimal shape this module needs from an authenticated Octokit -- injectable so tests exercise this + * module's OWN append/error logic against a scripted response, never a real GitHub Octokit instance or + * network call. The caller (the scheduling job, #9274) constructs the real one via + * `makeInstallationOctokit` + `withInstallationTokenRetry`, matching every other GitHub write in this repo; + * installation-token resolution is deliberately NOT this module's concern. */ +export type GitHubContentsRequester = { + request: (route: string, params: Record) => Promise<{ data: unknown }>; +}; + +export type LedgerAnchorGitTarget = { owner: string; repo: string; branch: string; path: string }; + +function decodeBase64(base64: string): string { + const binary = atob(base64.replace(/\s+/g, "")); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return new TextDecoder().decode(bytes); +} + +function encodeBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** One JSONL line: the same canonicalized payload and signature Rekor anchors, so the two backends commit to + * the identical fact -- never a reshaped or lossy copy. */ +export function buildAnchorLogLine(signed: SignedLedgerAnchor): string { + return `${canonicalJson({ payload: signed.payload, signature: signed.signature, keyId: signed.keyId })}\n`; +} + +/** + * Append one anchor to the JSONL file, via the Contents API's read-modify-write with the file's own `sha` as + * a compare-and-swap guard (GitHub 409s a stale-sha PUT, which reaches the caller as a normal thrown error -- + * a genuine concurrent-writer race, distinct from every other failure mode this function already handles). + * Never throws past the caller: any error -- missing repo, auth failure, rate limit, a raced sha -- records a + * `status: 'failed'` row via #9271's persistence, matching the Rekor backend's identical posture. + */ +export async function submitToGitAnchor(env: Env, signed: SignedLedgerAnchor, octokit: GitHubContentsRequester, target: LedgerAnchorGitTarget): Promise { + const { owner, repo, branch, path } = target; + try { + let existingSha: string | undefined; + let existingContent = ""; + try { + const response = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref: branch }); + const data = response.data as { content?: string; sha?: string }; + if (typeof data.content === "string") existingContent = decodeBase64(data.content); + existingSha = data.sha; + } catch (error) { + if (githubErrorStatus(error) !== 404) throw error; + // 404 = first anchor ever committed to this file; start from empty, no sha to compare-and-swap against. + } + + const updatedContent = existingContent + buildAnchorLogLine(signed); + const response = await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", { + owner, + repo, + path, + branch, + message: `chore(anchor): decision ledger seq ${signed.payload.seq}`, + content: encodeBase64(updatedContent), + ...(existingSha !== undefined && { sha: existingSha }), + }); + const commitSha = (response.data as { commit?: { sha?: string } }).commit?.sha; + if (typeof commitSha !== "string") { + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "git", + status: "failed", + error: "GitHub Contents API response did not include a commit sha", + }); + return; + } + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "git", + status: "ok", + backendRef: { owner, repo, branch, path, sha: commitSha }, + proofR2Key: null, + }); + } catch (error) { + await recordLedgerAnchorAttempt(env, { + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + backend: "git", + status: "failed", + error, + }); + } +} diff --git a/src/review/ledger-anchor.ts b/src/review/ledger-anchor.ts index c57961107..ac12df6c1 100644 --- a/src/review/ledger-anchor.ts +++ b/src/review/ledger-anchor.ts @@ -210,7 +210,6 @@ export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string): return keys.find((key) => key.keyId === keyId) ?? null; } -/** Digest helper re-exported so an anchor consumer never needs a second import just to hash a payload. */ /** Digest helpers re-exported so an anchor consumer (e.g. the git-commit backend, #9273, which commits the * same canonicalized payload Rekor anchors) never needs a second import from decision-record.ts just to * canonicalize or hash something alongside a signed anchor. */ diff --git a/test/unit/ledger-anchor-git.test.ts b/test/unit/ledger-anchor-git.test.ts new file mode 100644 index 000000000..78cff33c5 --- /dev/null +++ b/test/unit/ledger-anchor-git.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { buildAnchorLogLine, submitToGitAnchor, type GitHubContentsRequester } from "../../src/review/ledger-anchor-git"; +import { buildLedgerAnchorPayload, canonicalJson, type SignedLedgerAnchor } from "../../src/review/ledger-anchor"; +import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence"; + +// #9273 (epic #9267). octokit is ALWAYS injected -- never a real GitHub write. The property under test is +// this module's own append/error/persistence logic, exercised against a scripted GitHubContentsRequester. + +const TARGET = { owner: "acme", repo: "loopover-anchors", branch: "main", path: "anchors.jsonl" }; + +function makeSignedAnchor(seq = 1): SignedLedgerAnchor { + return { payload: buildLedgerAnchorPayload({ seq, rowHash: "a".repeat(64), totalCount: seq }, "2026-07-27T12:00:00.000Z"), signature: "c2ln", keyId: "key1" }; +} + +function decodeBase64(base64: string): string { + return Buffer.from(base64, "base64").toString("utf8"); +} + +describe("buildAnchorLogLine (#9273)", () => { + it("commits the SAME canonicalized payload and signature as the Rekor backend anchors -- the two never diverge", () => { + const signed = makeSignedAnchor(); + const line = buildAnchorLogLine(signed); + expect(line.endsWith("\n")).toBe(true); + expect(JSON.parse(line)).toEqual({ payload: JSON.parse(canonicalJson(signed.payload)), signature: signed.signature, keyId: signed.keyId }); + }); +}); + +describe("submitToGitAnchor (#9273)", () => { + it("creates the file on first anchor ever (no prior sha to compare-and-swap against)", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(1); + const request = vi.fn(async (route: string, params: Record) => { + if (route.startsWith("GET")) { + const error = new Error("Not Found") as Error & { status: number }; + error.status = 404; + throw error; + } + expect(params["sha"]).toBeUndefined(); // no sha on a brand-new file + expect(decodeBase64(params["content"] as string)).toBe(buildAnchorLogLine(signed)); + return { data: { commit: { sha: "deadbeef1" } } }; + }); + + await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ + seq: 1, + backend: "git", + status: "ok", + backendRef: { owner: "acme", repo: "loopover-anchors", branch: "main", path: "anchors.jsonl", sha: "deadbeef1" }, + }); + }); + + it("APPENDS to existing content rather than overwriting it, using the existing sha as a compare-and-swap guard", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(2); + const priorLine = '{"prior":"entry"}\n'; + const request = vi.fn(async (route: string, params: Record) => { + if (route.startsWith("GET")) return { data: { content: Buffer.from(priorLine).toString("base64"), sha: "old-sha" } }; + expect(params["sha"]).toBe("old-sha"); + expect(decodeBase64(params["content"] as string)).toBe(priorLine + buildAnchorLogLine(signed)); + return { data: { commit: { sha: "newsha2" } } }; + }); + + await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]?.backendRef).toMatchObject({ sha: "newsha2" }); + }); + + it("treats a GET response with a sha but no inline content (GitHub omits it for files >1MB) as empty existing content, not a crash", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(9); + const request = vi.fn(async (route: string, params: Record) => { + if (route.startsWith("GET")) return { data: { sha: "large-file-sha" } }; // no `content` field + expect(params["sha"]).toBe("large-file-sha"); // still used for compare-and-swap + expect(decodeBase64(params["content"] as string)).toBe(buildAnchorLogLine(signed)); // not prefixed with garbage + return { data: { commit: { sha: "afterlarge" } } }; + }); + + await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET); + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ status: "ok", backendRef: { sha: "afterlarge" } }); + }); + + it("records status:'failed' (with the real error, not a thrown one) on a rate-limit / auth error", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(3); + const request = vi.fn(async (route: string) => { + if (route.startsWith("GET")) { + const error = new Error("Not Found") as Error & { status: number }; + error.status = 404; + throw error; + } + const error = new Error("API rate limit exceeded") as Error & { status: number }; + error.status = 403; + throw error; + }); + + await expect(submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET)).resolves.toBeUndefined(); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ backend: "git", status: "failed", error: "API rate limit exceeded" }); + }); + + it("records status:'failed' when a non-404 GET error occurs (never conflated with 'file does not exist yet')", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(4); + const request = vi.fn(async () => { + const error = new Error("Server Error") as Error & { status: number }; + error.status = 500; + throw error; + }); + + await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET); + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ status: "failed", error: "Server Error" }); + }); + + it("records status:'failed' if the PUT response is missing a commit sha", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(5); + const request = vi.fn(async (route: string) => { + if (route.startsWith("GET")) { + const error = new Error("Not Found") as Error & { status: number }; + error.status = 404; + throw error; + } + return { data: {} }; // no commit.sha + }); + + await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET); + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]).toMatchObject({ status: "failed", error: "GitHub Contents API response did not include a commit sha" }); + }); + + it("never throws past the caller, whatever the failure", async () => { + const env = createTestEnv(); + const signed = makeSignedAnchor(6); + const request = vi.fn(async () => { + throw new Error("anything"); + }); + await expect(submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET)).resolves.toBeUndefined(); + }); +});