Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
116 changes: 116 additions & 0 deletions src/review/ledger-anchor-git.ts
Original file line number Diff line number Diff line change
@@ -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=="<owner>/<repo>")'
// 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<string, unknown>) => 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<void> {
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,
});
}
}
6 changes: 5 additions & 1 deletion src/review/ledger-anchor-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
170 changes: 170 additions & 0 deletions src/review/ledger-anchor-rekor.ts
Original file line number Diff line number Diff line change
@@ -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<HashedRekordRequestV002> {
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<string, unknown>);
const entry = entries[0];
if (typeof entry !== "object" || entry === null) return null;
const candidate = entry as Record<string, unknown>;
const logId = candidate["logId"];
if (
typeof candidate["logIndex"] !== "number" ||
typeof candidate["uuid"] !== "string" ||
typeof logId !== "object" ||
logId === null ||
typeof (logId as Record<string, unknown>)["keyId"] !== "string"
) {
return null;
}
return { logIndex: candidate["logIndex"], uuid: candidate["uuid"], logId: { keyId: (logId as Record<string, unknown>)["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<void> {
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,
});
}
}
1 change: 0 additions & 1 deletion src/review/ledger-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading