diff --git a/src/env.d.ts b/src/env.d.ts index 934ba5c214..18d7c2faa1 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-git.ts b/src/review/ledger-anchor-git.ts new file mode 100644 index 0000000000..458cff2f38 --- /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-persistence.ts b/src/review/ledger-anchor-persistence.ts index a21820253c..044ed53b20 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 0000000000..6e6ae5f648 --- /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/src/review/ledger-anchor.ts b/src/review/ledger-anchor.ts index c57961107a..ac12df6c13 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 0000000000..78cff33c50 --- /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(); + }); +}); diff --git a/test/unit/ledger-anchor-rekor.test.ts b/test/unit/ledger-anchor-rekor.test.ts new file mode 100644 index 0000000000..449640c2dd --- /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" }); + }); +});