Skip to content

Commit 1c7a6b8

Browse files
committed
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.
1 parent fed8ba6 commit 1c7a6b8

3 files changed

Lines changed: 262 additions & 1 deletion

File tree

src/review/ledger-anchor-git.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Git-commit anchoring backend (#9273, epic #9267). Secondary, complementary to Rekor (#9272): a commit
2+
// appended to `anchors.jsonl` in a public repo, via the GitHub Contents API and the SAME installation-token
3+
// chokepoint (makeInstallationOctokit) every other GitHub write in this engine goes through -- never a
4+
// direct fetch to the GitHub API.
5+
//
6+
// Alone this is weaker than Rekor: GitHub is a trusted third party, and `git push --force` rewrites it. It
7+
// becomes genuinely strong combined with mirrors nobody at LoopOver controls -- GH Archive's hourly
8+
// `PushEvent` export and Software Heritage's on-demand "Save Code Now" archival -- which is why this backend
9+
// exists as a SECOND, independent anchor for the same checkpoint rather than a replacement for Rekor.
10+
//
11+
// Cross-mirror verification, for a skeptic who does not want to trust this repo's own git history alone:
12+
// 1. `git clone` the anchors repo and `git log --oneline -- anchors.jsonl` to find the commit for a seq.
13+
// 2. Cross-check that push actually happened when claimed, from an archive LoopOver does not control:
14+
// curl -s "https://data.gharchive.org/YYYY-MM-DD-HH.json.gz" | gunzip \
15+
// | jq 'select(.type=="PushEvent" and .repo.name=="<owner>/<repo>")'
16+
// A commit present in the anchors repo but ABSENT from that hour's GH Archive export (once the day
17+
// finishes being written) is exactly the signal a rewrite would leave behind.
18+
import { githubErrorStatus } from "../github/app";
19+
import { canonicalJson, type SignedLedgerAnchor } from "./ledger-anchor";
20+
import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence";
21+
22+
/** Minimal shape this module needs from an authenticated Octokit -- injectable so tests exercise this
23+
* module's OWN append/error logic against a scripted response, never a real GitHub Octokit instance or
24+
* network call. The caller (the scheduling job, #9274) constructs the real one via
25+
* `makeInstallationOctokit` + `withInstallationTokenRetry`, matching every other GitHub write in this repo;
26+
* installation-token resolution is deliberately NOT this module's concern. */
27+
export type GitHubContentsRequester = {
28+
request: (route: string, params: Record<string, unknown>) => Promise<{ data: unknown }>;
29+
};
30+
31+
export type LedgerAnchorGitTarget = { owner: string; repo: string; branch: string; path: string };
32+
33+
function decodeBase64(base64: string): string {
34+
const binary = atob(base64.replace(/\s+/g, ""));
35+
const bytes = new Uint8Array(binary.length);
36+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
37+
return new TextDecoder().decode(bytes);
38+
}
39+
40+
function encodeBase64(text: string): string {
41+
const bytes = new TextEncoder().encode(text);
42+
let binary = "";
43+
for (const byte of bytes) binary += String.fromCharCode(byte);
44+
return btoa(binary);
45+
}
46+
47+
/** One JSONL line: the same canonicalized payload and signature Rekor anchors, so the two backends commit to
48+
* the identical fact -- never a reshaped or lossy copy. */
49+
export function buildAnchorLogLine(signed: SignedLedgerAnchor): string {
50+
return `${canonicalJson({ payload: signed.payload, signature: signed.signature, keyId: signed.keyId })}\n`;
51+
}
52+
53+
/**
54+
* Append one anchor to the JSONL file, via the Contents API's read-modify-write with the file's own `sha` as
55+
* a compare-and-swap guard (GitHub 409s a stale-sha PUT, which reaches the caller as a normal thrown error --
56+
* a genuine concurrent-writer race, distinct from every other failure mode this function already handles).
57+
* Never throws past the caller: any error -- missing repo, auth failure, rate limit, a raced sha -- records a
58+
* `status: 'failed'` row via #9271's persistence, matching the Rekor backend's identical posture.
59+
*/
60+
export async function submitToGitAnchor(env: Env, signed: SignedLedgerAnchor, octokit: GitHubContentsRequester, target: LedgerAnchorGitTarget): Promise<void> {
61+
const { owner, repo, branch, path } = target;
62+
try {
63+
let existingSha: string | undefined;
64+
let existingContent = "";
65+
try {
66+
const response = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref: branch });
67+
const data = response.data as { content?: string; sha?: string };
68+
if (typeof data.content === "string") existingContent = decodeBase64(data.content);
69+
existingSha = data.sha;
70+
} catch (error) {
71+
if (githubErrorStatus(error) !== 404) throw error;
72+
// 404 = first anchor ever committed to this file; start from empty, no sha to compare-and-swap against.
73+
}
74+
75+
const updatedContent = existingContent + buildAnchorLogLine(signed);
76+
const response = await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", {
77+
owner,
78+
repo,
79+
path,
80+
branch,
81+
message: `chore(anchor): decision ledger seq ${signed.payload.seq}`,
82+
content: encodeBase64(updatedContent),
83+
...(existingSha !== undefined && { sha: existingSha }),
84+
});
85+
const commitSha = (response.data as { commit?: { sha?: string } }).commit?.sha;
86+
if (typeof commitSha !== "string") {
87+
await recordLedgerAnchorAttempt(env, {
88+
payload: signed.payload,
89+
signature: signed.signature,
90+
keyId: signed.keyId,
91+
backend: "git",
92+
status: "failed",
93+
error: "GitHub Contents API response did not include a commit sha",
94+
});
95+
return;
96+
}
97+
await recordLedgerAnchorAttempt(env, {
98+
payload: signed.payload,
99+
signature: signed.signature,
100+
keyId: signed.keyId,
101+
backend: "git",
102+
status: "ok",
103+
backendRef: { owner, repo, branch, path, sha: commitSha },
104+
proofR2Key: null,
105+
});
106+
} catch (error) {
107+
await recordLedgerAnchorAttempt(env, {
108+
payload: signed.payload,
109+
signature: signed.signature,
110+
keyId: signed.keyId,
111+
backend: "git",
112+
status: "failed",
113+
error,
114+
});
115+
}
116+
}

src/review/ledger-anchor.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string):
210210
return keys.find((key) => key.keyId === keyId) ?? null;
211211
}
212212

213-
/** Digest helper re-exported so an anchor consumer never needs a second import just to hash a payload. */
214213
/** Digest helpers re-exported so an anchor consumer (e.g. the git-commit backend, #9273, which commits the
215214
* same canonicalized payload Rekor anchors) never needs a second import from decision-record.ts just to
216215
* canonicalize or hash something alongside a signed anchor. */
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createTestEnv } from "../helpers/d1";
3+
import { buildAnchorLogLine, submitToGitAnchor, type GitHubContentsRequester } from "../../src/review/ledger-anchor-git";
4+
import { buildLedgerAnchorPayload, canonicalJson, type SignedLedgerAnchor } from "../../src/review/ledger-anchor";
5+
import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence";
6+
7+
// #9273 (epic #9267). octokit is ALWAYS injected -- never a real GitHub write. The property under test is
8+
// this module's own append/error/persistence logic, exercised against a scripted GitHubContentsRequester.
9+
10+
const TARGET = { owner: "acme", repo: "loopover-anchors", branch: "main", path: "anchors.jsonl" };
11+
12+
function makeSignedAnchor(seq = 1): SignedLedgerAnchor {
13+
return { payload: buildLedgerAnchorPayload({ seq, rowHash: "a".repeat(64), totalCount: seq }, "2026-07-27T12:00:00.000Z"), signature: "c2ln", keyId: "key1" };
14+
}
15+
16+
function decodeBase64(base64: string): string {
17+
return Buffer.from(base64, "base64").toString("utf8");
18+
}
19+
20+
describe("buildAnchorLogLine (#9273)", () => {
21+
it("commits the SAME canonicalized payload and signature as the Rekor backend anchors -- the two never diverge", () => {
22+
const signed = makeSignedAnchor();
23+
const line = buildAnchorLogLine(signed);
24+
expect(line.endsWith("\n")).toBe(true);
25+
expect(JSON.parse(line)).toEqual({ payload: JSON.parse(canonicalJson(signed.payload)), signature: signed.signature, keyId: signed.keyId });
26+
});
27+
});
28+
29+
describe("submitToGitAnchor (#9273)", () => {
30+
it("creates the file on first anchor ever (no prior sha to compare-and-swap against)", async () => {
31+
const env = createTestEnv();
32+
const signed = makeSignedAnchor(1);
33+
const request = vi.fn(async (route: string, params: Record<string, unknown>) => {
34+
if (route.startsWith("GET")) {
35+
const error = new Error("Not Found") as Error & { status: number };
36+
error.status = 404;
37+
throw error;
38+
}
39+
expect(params["sha"]).toBeUndefined(); // no sha on a brand-new file
40+
expect(decodeBase64(params["content"] as string)).toBe(buildAnchorLogLine(signed));
41+
return { data: { commit: { sha: "deadbeef1" } } };
42+
});
43+
44+
await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET);
45+
46+
const { anchors } = await loadPublicLedgerAnchors(env);
47+
expect(anchors[0]).toMatchObject({
48+
seq: 1,
49+
backend: "git",
50+
status: "ok",
51+
backendRef: { owner: "acme", repo: "loopover-anchors", branch: "main", path: "anchors.jsonl", sha: "deadbeef1" },
52+
});
53+
});
54+
55+
it("APPENDS to existing content rather than overwriting it, using the existing sha as a compare-and-swap guard", async () => {
56+
const env = createTestEnv();
57+
const signed = makeSignedAnchor(2);
58+
const priorLine = '{"prior":"entry"}\n';
59+
const request = vi.fn(async (route: string, params: Record<string, unknown>) => {
60+
if (route.startsWith("GET")) return { data: { content: Buffer.from(priorLine).toString("base64"), sha: "old-sha" } };
61+
expect(params["sha"]).toBe("old-sha");
62+
expect(decodeBase64(params["content"] as string)).toBe(priorLine + buildAnchorLogLine(signed));
63+
return { data: { commit: { sha: "newsha2" } } };
64+
});
65+
66+
await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET);
67+
68+
const { anchors } = await loadPublicLedgerAnchors(env);
69+
expect(anchors[0]?.backendRef).toMatchObject({ sha: "newsha2" });
70+
});
71+
72+
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 () => {
73+
const env = createTestEnv();
74+
const signed = makeSignedAnchor(9);
75+
const request = vi.fn(async (route: string, params: Record<string, unknown>) => {
76+
if (route.startsWith("GET")) return { data: { sha: "large-file-sha" } }; // no `content` field
77+
expect(params["sha"]).toBe("large-file-sha"); // still used for compare-and-swap
78+
expect(decodeBase64(params["content"] as string)).toBe(buildAnchorLogLine(signed)); // not prefixed with garbage
79+
return { data: { commit: { sha: "afterlarge" } } };
80+
});
81+
82+
await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET);
83+
const { anchors } = await loadPublicLedgerAnchors(env);
84+
expect(anchors[0]).toMatchObject({ status: "ok", backendRef: { sha: "afterlarge" } });
85+
});
86+
87+
it("records status:'failed' (with the real error, not a thrown one) on a rate-limit / auth error", async () => {
88+
const env = createTestEnv();
89+
const signed = makeSignedAnchor(3);
90+
const request = vi.fn(async (route: string) => {
91+
if (route.startsWith("GET")) {
92+
const error = new Error("Not Found") as Error & { status: number };
93+
error.status = 404;
94+
throw error;
95+
}
96+
const error = new Error("API rate limit exceeded") as Error & { status: number };
97+
error.status = 403;
98+
throw error;
99+
});
100+
101+
await expect(submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET)).resolves.toBeUndefined();
102+
103+
const { anchors } = await loadPublicLedgerAnchors(env);
104+
expect(anchors[0]).toMatchObject({ backend: "git", status: "failed", error: "API rate limit exceeded" });
105+
});
106+
107+
it("records status:'failed' when a non-404 GET error occurs (never conflated with 'file does not exist yet')", async () => {
108+
const env = createTestEnv();
109+
const signed = makeSignedAnchor(4);
110+
const request = vi.fn(async () => {
111+
const error = new Error("Server Error") as Error & { status: number };
112+
error.status = 500;
113+
throw error;
114+
});
115+
116+
await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET);
117+
const { anchors } = await loadPublicLedgerAnchors(env);
118+
expect(anchors[0]).toMatchObject({ status: "failed", error: "Server Error" });
119+
});
120+
121+
it("records status:'failed' if the PUT response is missing a commit sha", async () => {
122+
const env = createTestEnv();
123+
const signed = makeSignedAnchor(5);
124+
const request = vi.fn(async (route: string) => {
125+
if (route.startsWith("GET")) {
126+
const error = new Error("Not Found") as Error & { status: number };
127+
error.status = 404;
128+
throw error;
129+
}
130+
return { data: {} }; // no commit.sha
131+
});
132+
133+
await submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET);
134+
const { anchors } = await loadPublicLedgerAnchors(env);
135+
expect(anchors[0]).toMatchObject({ status: "failed", error: "GitHub Contents API response did not include a commit sha" });
136+
});
137+
138+
it("never throws past the caller, whatever the failure", async () => {
139+
const env = createTestEnv();
140+
const signed = makeSignedAnchor(6);
141+
const request = vi.fn(async () => {
142+
throw new Error("anything");
143+
});
144+
await expect(submitToGitAnchor(env, signed, { request } as GitHubContentsRequester, TARGET)).resolves.toBeUndefined();
145+
});
146+
});

0 commit comments

Comments
 (0)