Skip to content

Commit fed8ba6

Browse files
committed
feat(ledger): Rekor v2 hashedrekord anchoring backend (#9272)
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.
1 parent 0a93e29 commit fed8ba6

4 files changed

Lines changed: 310 additions & 1 deletion

File tree

src/env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,12 @@ declare global {
615615
* pre-#9267 posture, so an unprovisioned key degrades honestly instead of failing. See
616616
* review/ledger-anchor.ts. */
617617
LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY?: string;
618+
/** External ledger anchoring (#9272, epic #9267): the Rekor v2 shard base URL to submit anchors to.
619+
* Rekor shards ANNUALLY (log2025-1, log2026-1, ...) and the project's own guidance is explicit: never
620+
* hardcode a log URL. Defaults to the current shard as of when this was written if unset — an operator
621+
* updates this var at the next rotation rather than needing a code change. See
622+
* review/ledger-anchor-rekor.ts. */
623+
LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL?: string;
618624
/** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the
619625
* /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo.
620626
* Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */

src/review/ledger-anchor-persistence.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ export async function recordLedgerAnchorAttempt(env: Env, attempt: LedgerAnchorA
5151
const payloadJson = JSON.stringify(attempt.payload);
5252
const backendRef = attempt.status === "ok" ? JSON.stringify(attempt.backendRef) : null;
5353
const proofR2Key = attempt.status === "ok" ? attempt.proofR2Key : null;
54-
const error = attempt.status === "failed" ? errorMessage(attempt.error).slice(0, 500) : null;
54+
// A backend's own error is `unknown`: it may already be a hand-built descriptive string (the common case --
55+
// "Rekor responded 429: ...") or a genuine thrown Error (a network exception) -- errorMessage() alone only
56+
// recognizes the latter, collapsing an already-good string to its generic fallback. Use the string as-is;
57+
// only fall back to errorMessage()'s Error-extraction for anything else.
58+
const error = attempt.status === "failed" ? (typeof attempt.error === "string" ? attempt.error : errorMessage(attempt.error)).slice(0, 500) : null;
5559

5660
await env.DB.prepare(
5761
`INSERT INTO decision_ledger_anchors

src/review/ledger-anchor-rekor.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Rekor v2 hashedrekord anchoring backend (#9272, epic #9267). Primary anchoring mechanism per the mechanism
2+
// research on #9267: a plain hash + signature is exactly what `hashedrekord` is for -- Rekor never sees the
3+
// anchor payload itself, only a digest and a signature over it, plus the self-managed verifier public key
4+
// from #9270. No Fulcio, no OIDC, no new dependency (ECDSA P-256/SHA-256 is native WebCrypto). Free, ~200
5+
// byte payload, 99.5% SLO.
6+
import type { SignedLedgerAnchor } from "./ledger-anchor";
7+
import { anchorSigningInput } from "./ledger-anchor";
8+
import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence";
9+
10+
/** Rekor shards annually and the research is explicit: do not hardcode a log URL. Configurable via env,
11+
* with a fallback to the current shard as of when this was written -- an operator updates the env var at
12+
* the next rotation rather than this needing a code change. */
13+
const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2026-1.rekor.sigstore.dev";
14+
15+
/** The exact `hashedRekordRequestV002` body Rekor v2's `POST /api/v2/log/entries` accepts. Digest and
16+
* signature are both base64 per the API; `keyDetails` names the algorithm so Rekor can verify without
17+
* guessing. */
18+
export type HashedRekordRequestV002 = {
19+
hashedRekordRequestV002: {
20+
digest: string;
21+
signature: {
22+
content: string;
23+
verifier: {
24+
publicKey: { rawBytes: string };
25+
keyDetails: "PKIX_ECDSA_P256_SHA_256";
26+
};
27+
};
28+
};
29+
};
30+
31+
/** The subset of Rekor's `TransparencyLogEntry` response this module reads. The full response (including the
32+
* inclusion proof and signed checkpoint) is what would let a verifier check inclusion fully OFFLINE without
33+
* trusting Rekor's continued availability -- storing that blob is deliberately deferred past this PR (see
34+
* this module's own header on `proofR2Key`), but the fields below are enough for ONLINE verification via
35+
* `rekor-cli verify --uuid ... --artifact-hash ...` today. */
36+
export type RekorTransparencyLogEntryResponse = {
37+
logIndex: number;
38+
logId: { keyId: string };
39+
/** Rekor's own entry identifier, the `uuid` a verifier passes to `rekor-cli verify`. Present as the
40+
* response object's own key in the v2 API (one entry per request), not a fixed field name. */
41+
uuid: string;
42+
};
43+
44+
/**
45+
* Build the exact request body Rekor v2 expects, from an already-signed anchor and the matching published
46+
* public key. PURE and synchronous -- the digest itself needs one async hash, so this returns a Promise, but
47+
* makes no network call.
48+
*/
49+
export async function buildHashedRekordRequest(signed: SignedLedgerAnchor, publicKeySpkiBase64: string): Promise<HashedRekordRequestV002> {
50+
const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(anchorSigningInput(signed.payload)));
51+
const digest = base64Encode(new Uint8Array(digestBytes));
52+
return {
53+
hashedRekordRequestV002: {
54+
digest,
55+
signature: {
56+
content: signed.signature,
57+
verifier: {
58+
publicKey: { rawBytes: publicKeySpkiBase64 },
59+
keyDetails: "PKIX_ECDSA_P256_SHA_256",
60+
},
61+
},
62+
},
63+
};
64+
}
65+
66+
function base64Encode(bytes: Uint8Array): string {
67+
let binary = "";
68+
for (const byte of bytes) binary += String.fromCharCode(byte);
69+
return btoa(binary);
70+
}
71+
72+
/**
73+
* Parse the fields this module needs out of Rekor's raw JSON response. Rekor v2 nests the entry under a
74+
* dynamic key (the submitted entry's own uuid) rather than a fixed field name -- this reads the first (and
75+
* only, for a single-entry submission) value. Returns `null` for any response shape that doesn't match,
76+
* rather than throwing, so a Rekor API change degrades to a recorded failure instead of an unhandled crash.
77+
*/
78+
export function parseRekorResponse(raw: unknown): RekorTransparencyLogEntryResponse | null {
79+
if (typeof raw !== "object" || raw === null) return null;
80+
const entries = Object.values(raw as Record<string, unknown>);
81+
const entry = entries[0];
82+
if (typeof entry !== "object" || entry === null) return null;
83+
const candidate = entry as Record<string, unknown>;
84+
const logId = candidate["logId"];
85+
if (
86+
typeof candidate["logIndex"] !== "number" ||
87+
typeof candidate["uuid"] !== "string" ||
88+
typeof logId !== "object" ||
89+
logId === null ||
90+
typeof (logId as Record<string, unknown>)["keyId"] !== "string"
91+
) {
92+
return null;
93+
}
94+
return { logIndex: candidate["logIndex"], uuid: candidate["uuid"], logId: { keyId: (logId as Record<string, unknown>)["keyId"] as string } };
95+
}
96+
97+
/**
98+
* Submit a signed anchor to Rekor v2 and record the outcome via #9271's persistence -- success or failure,
99+
* always. Never throws: a network error, a non-2xx response, or an unparseable response body all become a
100+
* `status: 'failed'` row with the error, matching this backend's own issue text ("must not throw past the
101+
* caller", so #9273's git backend still gets attempted even if this one fails).
102+
*
103+
* `fetchImpl` is injectable so tests exercise this function's own logic against a scripted response, never a
104+
* real network call to Rekor.
105+
*/
106+
export async function submitToRekor(
107+
env: Env,
108+
signed: SignedLedgerAnchor,
109+
publicKeySpkiBase64: string,
110+
fetchImpl: typeof fetch = fetch,
111+
): Promise<void> {
112+
const shardBaseUrl = env.LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL ?? DEFAULT_REKOR_SHARD_BASE_URL;
113+
try {
114+
const body = await buildHashedRekordRequest(signed, publicKeySpkiBase64);
115+
// v2 batches submissions -- a short timeout would misread a slow-but-successful submission as failure.
116+
const response = await fetchImpl(`${shardBaseUrl}/api/v2/log/entries`, {
117+
method: "POST",
118+
headers: { "content-type": "application/json" },
119+
body: JSON.stringify(body),
120+
signal: AbortSignal.timeout(20_000),
121+
});
122+
if (!response.ok) {
123+
await recordLedgerAnchorAttempt(env, {
124+
payload: signed.payload,
125+
signature: signed.signature,
126+
keyId: signed.keyId,
127+
backend: "rekor",
128+
status: "failed",
129+
error: `Rekor responded ${response.status}: ${(await response.text()).slice(0, 200)}`,
130+
});
131+
return;
132+
}
133+
const parsed = parseRekorResponse(await response.json());
134+
if (!parsed) {
135+
await recordLedgerAnchorAttempt(env, {
136+
payload: signed.payload,
137+
signature: signed.signature,
138+
keyId: signed.keyId,
139+
backend: "rekor",
140+
status: "failed",
141+
error: "Rekor response did not match the expected TransparencyLogEntry shape",
142+
});
143+
return;
144+
}
145+
await recordLedgerAnchorAttempt(env, {
146+
payload: signed.payload,
147+
signature: signed.signature,
148+
keyId: signed.keyId,
149+
backend: "rekor",
150+
status: "ok",
151+
backendRef: { shardBaseUrl, logIndex: parsed.logIndex, logIdKeyId: parsed.logId.keyId, uuid: parsed.uuid },
152+
// Deferred past this PR: storing the full TransparencyLogEntry (inclusion proof + signed checkpoint) in
153+
// R2 for fully offline verification. Online verification (rekor-cli against shardBaseUrl + uuid) works
154+
// fully without it today; the offline path is a documented enhancement, not a gap in this backend.
155+
proofR2Key: null,
156+
});
157+
} catch (error) {
158+
// Pass the raw caught value through, not a pre-stringified one -- #9271's persistence layer is the single
159+
// place that normalizes an unknown error into text (Error instance vs. anything else), so this backend
160+
// and every other one feed it the same undecided shape rather than each reimplementing that choice.
161+
await recordLedgerAnchorAttempt(env, {
162+
payload: signed.payload,
163+
signature: signed.signature,
164+
keyId: signed.keyId,
165+
backend: "rekor",
166+
status: "failed",
167+
error,
168+
});
169+
}
170+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createTestEnv } from "../helpers/d1";
3+
import { buildHashedRekordRequest, parseRekorResponse, submitToRekor } from "../../src/review/ledger-anchor-rekor";
4+
import { buildLedgerAnchorPayload, signLedgerAnchorPayload, type SignedLedgerAnchor } from "../../src/review/ledger-anchor";
5+
import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence";
6+
7+
// #9272 (epic #9267). fetch is ALWAYS injected -- never a real network call to Rekor. The property under
8+
// test is that this module's own request/response/persistence logic is correct; Rekor's actual API is out of
9+
// scope for a unit test and is exercised, if at all, by hand against the real service.
10+
11+
function bytesToBase64(bytes: Uint8Array): string {
12+
let binary = "";
13+
for (const byte of bytes) binary += String.fromCharCode(byte);
14+
return btoa(binary);
15+
}
16+
17+
async function realSignedAnchor(): Promise<{ signed: SignedLedgerAnchor; publicKeySpki: string }> {
18+
const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair;
19+
const pkcs8 = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer));
20+
const publicKeySpki = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer));
21+
const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${(pkcs8.match(/.{1,64}/g) ?? []).join("\n")}\n-----END PRIVATE KEY-----`;
22+
const payload = buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z");
23+
const signed = await signLedgerAnchorPayload(payload, privateKeyPem, "key1");
24+
return { signed, publicKeySpki };
25+
}
26+
27+
const REKOR_RESPONSE = { "24296fb24b8ad77a": { logIndex: 42, uuid: "24296fb24b8ad77a", logId: { keyId: "c2iga0d1" } } };
28+
29+
describe("buildHashedRekordRequest (#9272)", () => {
30+
it("builds the exact hashedRekordRequestV002 shape Rekor v2 expects", async () => {
31+
const { signed, publicKeySpki } = await realSignedAnchor();
32+
const request = await buildHashedRekordRequest(signed, publicKeySpki);
33+
34+
expect(request.hashedRekordRequestV002.signature.content).toBe(signed.signature);
35+
expect(request.hashedRekordRequestV002.signature.verifier.publicKey.rawBytes).toBe(publicKeySpki);
36+
expect(request.hashedRekordRequestV002.signature.verifier.keyDetails).toBe("PKIX_ECDSA_P256_SHA_256");
37+
expect(request.hashedRekordRequestV002.digest).not.toBe("");
38+
});
39+
40+
it("is deterministic: the same signed anchor always produces the same digest", async () => {
41+
const { signed, publicKeySpki } = await realSignedAnchor();
42+
const a = await buildHashedRekordRequest(signed, publicKeySpki);
43+
const b = await buildHashedRekordRequest(signed, publicKeySpki);
44+
expect(a.hashedRekordRequestV002.digest).toBe(b.hashedRekordRequestV002.digest);
45+
});
46+
});
47+
48+
describe("parseRekorResponse", () => {
49+
it("parses a real-shaped Rekor v2 response, reading the entry under its dynamic uuid key", () => {
50+
expect(parseRekorResponse(REKOR_RESPONSE)).toEqual({ logIndex: 42, uuid: "24296fb24b8ad77a", logId: { keyId: "c2iga0d1" } });
51+
});
52+
53+
it("returns null (never throws) for any response shape it does not recognize", () => {
54+
expect(parseRekorResponse(null)).toBeNull();
55+
expect(parseRekorResponse("a string")).toBeNull();
56+
expect(parseRekorResponse({})).toBeNull();
57+
expect(parseRekorResponse({ x: {} })).toBeNull();
58+
expect(parseRekorResponse({ x: { logIndex: "not-a-number", uuid: "u", logId: { keyId: "k" } } })).toBeNull();
59+
expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u", logId: null } })).toBeNull();
60+
expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u" } })).toBeNull();
61+
expect(parseRekorResponse({ x: { logIndex: 1, uuid: "u", logId: { keyId: 7 } } })).toBeNull();
62+
});
63+
});
64+
65+
describe("submitToRekor (#9272)", () => {
66+
it("records status:'ok' with the FULL resolvable backend_ref on a successful submission", async () => {
67+
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL: "https://log2026-1.rekor.sigstore.dev" });
68+
const { signed, publicKeySpki } = await realSignedAnchor();
69+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 }));
70+
71+
await submitToRekor(env, signed, publicKeySpki, fetchMock);
72+
73+
const { anchors } = await loadPublicLedgerAnchors(env);
74+
expect(anchors).toHaveLength(1);
75+
expect(anchors[0]).toMatchObject({
76+
seq: 1,
77+
backend: "rekor",
78+
status: "ok",
79+
backendRef: { shardBaseUrl: "https://log2026-1.rekor.sigstore.dev", logIndex: 42, logIdKeyId: "c2iga0d1", uuid: "24296fb24b8ad77a" },
80+
});
81+
expect(fetchMock).toHaveBeenCalledWith(
82+
"https://log2026-1.rekor.sigstore.dev/api/v2/log/entries",
83+
expect.objectContaining({ method: "POST" }),
84+
);
85+
});
86+
87+
it("uses the default shard URL when unconfigured", async () => {
88+
const env = createTestEnv();
89+
const { signed, publicKeySpki } = await realSignedAnchor();
90+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 }));
91+
await submitToRekor(env, signed, publicKeySpki, fetchMock);
92+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("rekor.sigstore.dev"), expect.anything());
93+
});
94+
95+
it("records status:'failed' on a non-2xx response, and does NOT throw", async () => {
96+
const env = createTestEnv();
97+
const { signed, publicKeySpki } = await realSignedAnchor();
98+
const fetchMock = vi.fn().mockResolvedValue(new Response("rate limited", { status: 429 }));
99+
100+
await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined();
101+
102+
const { anchors } = await loadPublicLedgerAnchors(env);
103+
expect(anchors[0]).toMatchObject({ status: "failed", backend: "rekor" });
104+
expect(anchors[0]?.error).toContain("429");
105+
});
106+
107+
it("records status:'failed' when the response body does not parse as a TransparencyLogEntry", async () => {
108+
const env = createTestEnv();
109+
const { signed, publicKeySpki } = await realSignedAnchor();
110+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ unexpected: "shape" }), { status: 201 }));
111+
112+
await submitToRekor(env, signed, publicKeySpki, fetchMock);
113+
114+
const { anchors } = await loadPublicLedgerAnchors(env);
115+
expect(anchors[0]?.status).toBe("failed");
116+
expect(anchors[0]?.error).toContain("expected TransparencyLogEntry shape");
117+
});
118+
119+
it("records status:'failed' (not a thrown error past the caller) on a network exception", async () => {
120+
const env = createTestEnv();
121+
const { signed, publicKeySpki } = await realSignedAnchor();
122+
const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
123+
124+
await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined();
125+
126+
const { anchors } = await loadPublicLedgerAnchors(env);
127+
expect(anchors[0]).toMatchObject({ status: "failed", error: "network down" });
128+
});
129+
});

0 commit comments

Comments
 (0)