diff --git a/apps/loopover-ui/content/docs/self-hosting-configuration.mdx b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx index 3d83063ab..00c6390a7 100644 --- a/apps/loopover-ui/content/docs/self-hosting-configuration.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx @@ -513,6 +513,68 @@ Lets LoopOver open a pull request that refreshes this repo's own `AGENTS.md`/`CL ]} /> +## Decision-ledger anchoring (optional) + +Your instance keeps a hash-chained ledger of every verdict it persists. That chain is **tamper-evident** +on its own — anyone can walk it and locate an edit. Anchoring makes it **tamper-proof against you**: a +scheduled job publishes a signed checkpoint of the chain's tip to places you do not control (a Sigstore +Rekor transparency log, and a git commit that GH Archive and Software Heritage mirror), so rewriting +history back past a published checkpoint would also require forging a signature or fabricating matching +evidence at an external mirror. + +**This is a self-host capability, and only a self-host one.** Hosted review execution is retired, so +`api.loopover.ai` never decides anything and has no chain of its own to anchor — its ledger endpoints +answer for an empty chain and say so (`status: "empty_ledger"`). The chain that matters is the one *your* +instance wrote, so anchoring runs where the decisions were made. + +Anchoring is **off until you provision a signing key**, and stays silent-but-visible until then: the public +anchors endpoint reports `status: "unconfigured"` rather than an empty list that could be mistaken for a +healthy idle instance. + +### 1. Generate the keypair + +```bash +npm run ledger:anchor-keygen +``` + +Prints both halves already in the encodings the runtime expects, with the key id derived from the public +half so the two cannot drift apart. It writes nothing to disk — the private key exists only in that output, +so run it on a machine you trust and paste straight into your secret store. + +### 2. Provision both halves + +| Variable | Secret? | What it is | +| --- | --- | --- | +| `LOOPOVER_LEDGER_ANCHOR_KEYS` | No — publish it | The JSON array of published public keys. Served verbatim by `/v1/public/decision-ledger/anchor-key`, which is how a third party verifies your anchors. | +| `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` | **Yes** | PKCS8 PEM. Treat it like any other credential: `wrangler secret put`, a compose secret, or a vault entry — never a committed file. | + +Optional, for the git-commit backend (Rekor needs no credentials and uses its public shard by default): + +| Variable | What it is | +| --- | --- | +| `LOOPOVER_LEDGER_ANCHOR_GIT_OWNER` / `_REPO` | The repo checkpoints are appended to. A dedicated, public, otherwise-empty repo is the intended shape. | +| `LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH` / `_PATH` | Default `main` / `anchors.jsonl`. | +| `LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID` | The GitHub App installation used to commit. Unset means the git backend simply does not run; Rekor is unaffected. | + +### 3. Confirm it is live + +```bash +curl -s "$ORB/v1/public/decision-ledger/anchors?limit=1" | jq '{status, anchor: .anchors[0]}' +``` + +`status` distinguishes the states that used to be indistinguishable: + +- `anchored` — checkpoints are publishing; the newest one is in `anchor`. +- `pending` — key provisioned and a ledger exists; the first checkpoint is due (hourly, or every 256 rows). +- `empty_ledger` — nothing has been decided yet. Expected on a fresh instance. +- `unconfigured` — no signing key. Anchoring is not running. + +### Rotating the key + +Keep the retired entry in `LOOPOVER_LEDGER_ANCHOR_KEYS` with its `notAfter` set to the rotation instant, +and append the new one. **A retired key is never removed, only closed** — anchors signed under it must stay +verifiable forever, and a verifier checking a two-year-old checkpoint needs the key it was signed with. + ## Next steps Configure the GitHub integration in [GitHub App and Orb](/docs/self-hosting-github-app), then add optional context through [AI providers](/docs/self-hosting-ai-providers), [REES](/docs/self-hosting-rees), or [RAG](/docs/self-hosting-rag). For the full gate-mode and per-repo settings reference — including the AI-review combine modes and a complete worked manifest — see [Tuning your reviews](/docs/tuning). diff --git a/apps/loopover-ui/content/docs/what-you-can-verify.mdx b/apps/loopover-ui/content/docs/what-you-can-verify.mdx index 6fa1d12b7..beefaa3d2 100644 --- a/apps/loopover-ui/content/docs/what-you-can-verify.mdx +++ b/apps/loopover-ui/content/docs/what-you-can-verify.mdx @@ -25,8 +25,14 @@ only tampering within it. Every persisted verdict appends to a hash-chained ledger — each row's hash covers the previous row's hash, so any edit to history breaks the chain at a point you can locate. +Run this against **the instance that reviewed the PR** — the host in the review comment's own links. +A ledger belongs to the runtime that made the decisions, so `api.loopover.ai` is the wrong host to +ask (see "Which host you ask matters" below): + ```bash -curl -s "https://api.loopover.ai/v1/public/decision-ledger/verify" | jq +ORB=https://orb.example.org # the instance that reviewed the PR + +curl -s "$ORB/v1/public/decision-ledger/verify" | jq ``` Returns `{ ok, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords }`, and a `break` @@ -63,13 +69,33 @@ Two response fields encode deliberate, published semantics rather than tolerance every row individually external-checkable in real time. + + **Which host you ask matters.** A decision ledger belongs to the runtime that *made* the decisions, + and hosted review execution is retired — only self-host runtimes execute reviews, so each one + writes and anchors **its own** chain in its own database. `api.loopover.ai` therefore has no ledger + of its own to anchor and never will: its `/v1/public/decision-ledger/*` endpoints answer for an + empty chain, and its `anchors` response says so explicitly via `status: "empty_ledger"` rather + than an ambiguous empty list. + + So every command on this page runs against `$ORB`, the instance whose reviews you are checking — + never against `api.loopover.ai`. + + This is a deliberate boundary, not a gap. Aggregating every instance's records into one central + chain would produce an anchored artifact that looks *more* authoritative while proving *less*: the + central chain would be a re-chained aggregate nobody's verifier cares about, and each instance's + real chain would still be unanchored. + + **Verify an anchor end to end** -a. Fetch the most recent anchor and the currently-published signing key: +a. Fetch the most recent anchor and the currently-published signing key. `status` tells you which +state you are in before you read the list — `anchored`, `empty_ledger` (nothing decided yet), +`unconfigured` (no signing key provisioned), or `pending` (a ledger exists and is due its first +anchor): ```bash -curl -s "https://api.loopover.ai/v1/public/decision-ledger/anchors?limit=1" | jq '.anchors[0]' > anchor.json -curl -s "https://api.loopover.ai/v1/public/decision-ledger/anchor-key" | jq -c '.keys' > anchor-keys.json +curl -s "$ORB/v1/public/decision-ledger/anchors?limit=1" | jq '{status, anchor: .anchors[0]}' > anchor.json +curl -s "$ORB/v1/public/decision-ledger/anchor-key" | jq -c '.keys' > anchor-keys.json ``` b. Fetch the actual signed payload the anchor committed to. For the git-commit backend, @@ -108,7 +134,7 @@ hash yourself: ```bash SEQ=$(jq -r '.payload.seq' anchor-signed.json) -curl -s "https://api.loopover.ai/v1/public/decision-ledger/row/$SEQ" | jq > row.json +curl -s "$ORB/v1/public/decision-ledger/row/$SEQ" | jq > row.json npx tsx -e ' import { readFileSync } from "node:fs"; import { ledgerRowHash } from "./src/review/decision-record.ts"; diff --git a/package.json b/package.json index b77c15b80..5499f6b69 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "deploy": "wrangler deploy", "deploy:api": "turbo run build --filter=@loopover/engine --filter=@loopover/contract && wrangler d1 migrations apply loopover --remote && wrangler deploy", "selfhost:postgres:migrate": "tsx scripts/migrate-selfhost-sqlite-to-postgres.ts", + "ledger:anchor-keygen": "tsx scripts/gen-ledger-anchor-keypair.ts", "selfhost:env-reference": "node --experimental-strip-types scripts/gen-selfhost-env-reference.ts", "selfhost:env-reference:check": "node --experimental-strip-types scripts/gen-selfhost-env-reference.ts --check", "miner:env-reference": "tsx packages/loopover-miner/scripts/generate-env-reference.ts", diff --git a/scripts/gen-ledger-anchor-keypair.ts b/scripts/gen-ledger-anchor-keypair.ts new file mode 100644 index 000000000..e920c55fa --- /dev/null +++ b/scripts/gen-ledger-anchor-keypair.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Generate the decision-ledger anchor signing keypair (#9719, epic #9267). +// +// Anchoring shipped with no way to provision the key it needs. Both halves of the scheduler's second guard +// (`LOOPOVER_LEDGER_ANCHOR_KEYS`, `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY`) had to be produced by hand, in the +// exact encodings `parseAnchorPublicKeys` and `signLedgerAnchorPayload` expect, with a keyId that is +// `computeAnchorKeyId` of the public half -- and nothing in the repo said how. An operator who guessed any +// of those wrong got `ledger_anchor_skipped_unconfigured` and an empty anchor list, which until #9755 was +// indistinguishable from a healthy idle instance. +// +// This prints both values ready to paste, deriving the keyId with the SAME function the runtime uses, so +// the published key and the anchors that reference it cannot disagree. +// +// npm run ledger:anchor-keygen +// +// PRINTS a private key to stdout. It is never written to disk, never committed, and the output is meant to +// go straight into your secret store (`wrangler secret put`, a compose env file, a vault entry). Run it on a +// machine you trust, and do not paste the private half into a shell history you keep. +import { computeAnchorKeyId } from "../src/review/ledger-anchor"; + +function toPem(base64: string, label: string): string { + return `-----BEGIN ${label}-----\n${(base64.match(/.{1,64}/g) ?? []).join("\n")}\n-----END ${label}-----`; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +export type GeneratedAnchorKeypair = { + keyId: string; + /** Exactly the string `LOOPOVER_LEDGER_ANCHOR_KEYS` takes -- a JSON array, parseable by + * `parseAnchorPublicKeys` without further massaging. */ + publishedKeys: string; + /** Exactly the string `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` takes -- PKCS8 PEM, importable by + * `signLedgerAnchorPayload`. */ + privateKeyPem: string; +}; + +/** + * Produce both halves in the encodings the runtime expects. Exported and returning values rather than + * printing them, so a test can assert the OUTPUT actually round-trips -- generating a key that the runtime + * then refuses is precisely the failure this script exists to prevent, and a print-only script could not be + * checked for it. + */ +export async function generateAnchorKeypair(now: string = new Date().toISOString()): Promise { + // P-256 / SHA-256 -- the pair `signLedgerAnchorPayload` imports and Rekor's `PKIX_ECDSA_P256_SHA_256` + // verifier expects. Any other curve produces anchors Rekor rejects and third parties cannot check. + 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)); + // Derived with the SAME function the runtime uses, so the published key and the anchors referencing it + // cannot disagree about the id. + const keyId = await computeAnchorKeyId(publicKeySpki); + // `notBefore` is now: an anchor signed before its key's validity window would not verify against it. + return { + keyId, + publishedKeys: JSON.stringify([{ keyId, publicKeySpki, notBefore: now, notAfter: null }]), + privateKeyPem: toPem(pkcs8, "PRIVATE KEY"), + }; +} + +async function main(): Promise { + const { keyId, publishedKeys, privateKeyPem } = await generateAnchorKeypair(); + + console.log("# ── LOOPOVER_LEDGER_ANCHOR_KEYS (public, safe to publish and to commit) ──"); + console.log("# Serve this verbatim; it is what /v1/public/decision-ledger/anchor-key returns."); + console.log(`LOOPOVER_LEDGER_ANCHOR_KEYS='${publishedKeys}'`); + console.log(""); + console.log("# ── LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY (SECRET — never commit) ──"); + console.log("# wrangler secret put LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY (paste the block below)"); + console.log(privateKeyPem); + console.log(""); + console.log(`# keyId ${keyId} — derived from the public half, so it cannot drift from the key it names.`); + console.log("# ROTATION: keep the retired entry in LOOPOVER_LEDGER_ANCHOR_KEYS with notAfter set to the"); + console.log("# rotation instant and append the new one. Anchors signed under the old key must stay"); + console.log("# verifiable forever, so a retired key is never removed -- only closed."); +} + +// Only when run directly, so importing this for a test does not print a private key into the test log. +if (process.argv[1]?.endsWith("gen-ledger-anchor-keypair.ts")) await main(); diff --git a/test/unit/gen-ledger-anchor-keypair.test.ts b/test/unit/gen-ledger-anchor-keypair.test.ts new file mode 100644 index 000000000..d81271b8a --- /dev/null +++ b/test/unit/gen-ledger-anchor-keypair.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { generateAnchorKeypair } from "../../scripts/gen-ledger-anchor-keypair"; +import { + anchorKeyById, + buildLedgerAnchorPayload, + computeAnchorKeyId, + currentAnchorKey, + parseAnchorPublicKeys, + signLedgerAnchorPayload, + verifyLedgerAnchorSignature, +} from "../../src/review/ledger-anchor"; + +// #9719: anchoring shipped with no way to provision the key it needs, so both halves had to be produced by +// hand in the exact encodings the runtime expects. A generator that emits a key the runtime then REFUSES +// would reproduce the original failure (`ledger_anchor_skipped_unconfigured`, empty anchor list) while +// looking like a fix — so these tests run the generated values through the real parse/sign/verify path +// rather than checking their shape. + +const AT = "2026-07-29T00:00:00.000Z"; + +describe("gen-ledger-anchor-keypair (#9719)", () => { + it("REGRESSION: the generated values feed the REAL runtime path end to end — parse, select, sign, verify", async () => { + const generated = await generateAnchorKeypair(AT); + + // 1. The published half parses through the same function the anchor-key route serves from. + const keys = parseAnchorPublicKeys(generated.publishedKeys); + expect(keys).toHaveLength(1); + // 2. It is SELECTABLE as the current key -- an entry the scheduler cannot pick is the exact + // `no_current_signing_key_published` early return that left anchors empty. + const current = currentAnchorKey(keys); + expect(current?.keyId).toBe(generated.keyId); + + // 3. The private half imports and signs. + const payload = buildLedgerAnchorPayload({ seq: 7, rowHash: "a".repeat(64), totalCount: 7 }, AT); + const signed = await signLedgerAnchorPayload(payload, generated.privateKeyPem, generated.keyId); + + // 4. And a third party holding ONLY the published half verifies it -- the whole point of the artifact. + const resolved = anchorKeyById(keys, signed.keyId); + expect(resolved).not.toBeNull(); + expect(await verifyLedgerAnchorSignature(signed, String(resolved?.publicKeySpki))).toBe(true); + }); + + it("the keyId is DERIVED from the public half, so the published key cannot drift from the id naming it", async () => { + const generated = await generateAnchorKeypair(AT); + const [published] = parseAnchorPublicKeys(generated.publishedKeys); + expect(await computeAnchorKeyId(String(published?.publicKeySpki))).toBe(generated.keyId); + }); + + it("emits an OPEN validity window at the supplied instant — a key valid from 'now', never expired", async () => { + const generated = await generateAnchorKeypair(AT); + const [published] = parseAnchorPublicKeys(generated.publishedKeys); + expect(published).toMatchObject({ notBefore: AT, notAfter: null }); + }); + + it("INVARIANT: every run is a fresh key — a generator that repeated itself would be catastrophic", async () => { + const [a, b] = await Promise.all([generateAnchorKeypair(AT), generateAnchorKeypair(AT)]); + expect(a.keyId).not.toBe(b.keyId); + expect(a.privateKeyPem).not.toBe(b.privateKeyPem); + }); + + it("REGRESSION: a signature does NOT verify against a different run's key", async () => { + // Guards the pairing itself: publishing key A while signing with key B would produce anchors that + // silently fail every third-party check. + const mine = await generateAnchorKeypair(AT); + const other = await generateAnchorKeypair(AT); + const signed = await signLedgerAnchorPayload( + buildLedgerAnchorPayload({ seq: 1, rowHash: "b".repeat(64), totalCount: 1 }, AT), + mine.privateKeyPem, + mine.keyId, + ); + const [otherPublished] = parseAnchorPublicKeys(other.publishedKeys); + expect(await verifyLedgerAnchorSignature(signed, String(otherPublished?.publicKeySpki))).toBe(false); + }); + + it("the PEM is real PKCS8 armor, so it pastes into a secret store unmodified", async () => { + const generated = await generateAnchorKeypair(AT); + expect(generated.privateKeyPem.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true); + expect(generated.privateKeyPem.trimEnd().endsWith("-----END PRIVATE KEY-----")).toBe(true); + // Wrapped at 64 columns like every other PEM, so a copy-paste through a YAML block stays valid. + const body = generated.privateKeyPem.split("\n").slice(1, -1); + for (const line of body) expect(line.length).toBeLessThanOrEqual(64); + }); +});