Public: anyone can read this; the crew can change it.

Cost ledger: the chain is committed to git in signed batches, so another forge can verify it #12282

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12113-cost-ledger-the-chain-is-committed-to-gi → main 3 commits fork

  • Merged. scout/12113-cost-ledger-the-chain-is-committed-to-gi was merged into main as 45be4c2.

Review

Sign in to review this change.
10 files changed +444 −13
docs/features/cost-ledger.md +11
@@ -19,3 +19,14 @@ Appends are synchronous and read the tip from the end of the files on every appe
1919 Prints `ok N entries`, or `broken at <file>:<line>: <why>` and exits 1.
2020
2121 The tests are `test/cost-ledger.test.ts`, not `test/ledger.test.ts`: that name is the decision ledger's.
22
23 ## Where the chain lives: git
24
25 The jsonl is the spool; git is truth. `src/core/ledger_git.ts` commits the chain to the `ledger` branch of a repository the forge owns: `FORGE_LEDGER_REPO` (`owner/name`), else `FORGE_REPO`; with neither, nothing is committed. A timer (`ledger-git`) fires hourly and, only when a month file has lines the branch lacks, appends them (one file per month, `<yyyy-mm>.jsonl` at the branch root) in **one commit**, signed with the forge's SSH host key (`<data>/ssh/host_ed25519`, an `SSHSIG` in the commit's `gpgsig` header, namespace `git`). No new lines, no commit. If the branch holds lines the spool does not, nothing is committed over it and the failure is logged. Entry text reaches git only as blob content, never as an argument.
26
27 ### Clone and verify (another forge, no database)
28
29 git clone <forge>/<owner>/<repo> ledger-clone
30 bun scripts/ledger-verify.ts --git ledger-clone --key "ssh-ed25519 AAAA…"
31
32 Walks the `ledger` branch, checks every commit's signature and the whole hash chain, and prints `ok N entries, M signed commits, key …`, or `broken at <file>:<line>: <why>` / `broken at <commit>: bad signature …` and exits 1. `--key` pins the signer (the forge's `host_ed25519.pub`); without it the commits must agree on one key, which is printed — compare it yourself. `--branch` names another branch.
scripts/ledger-verify.ts +62 −8
@@ -1,11 +1,65 @@
1 /** `bun scripts/ledger-verify.ts [dir]` — walk the cost ledger's chain; exit 1 on a break. */
1 /**
2 * `bun scripts/ledger-verify.ts [dir]` — walk the cost ledger's chain; exit 1 on a break.
3 * `bun scripts/ledger-verify.ts --git <repo-path> [--key "<ssh-ed25519 line>"] [--branch ledger]` —
4 * the same walk over the `ledger` branch of a clone, plus the signature of
5 * every commit on it; no database. `--key` pins the signer; without it the
6 * commits must agree on one key, which is printed so it can be compared.
7 */
28 import { join } from "node:path";
3 import { verifyDir } from "../src/core/ledger.ts";
9 import { verifyDir, verifyFiles } from "../src/core/ledger.ts";
10 import { checkMessage, splitCommit } from "../src/ssh/sshsig.ts";
411
5 const dir = process.argv[2] ?? join(process.env.FORGE_DATA ?? process.env.FORGE_APP_DATA ?? "data", "ledger");
6 const verdict = verifyDir(dir);
7 if (verdict.ok) console.log(`ok ${verdict.entries} entries`);
8 else {
9 console.log(`broken at ${verdict.at}: ${verdict.why}`);
10 process.exit(1);
12 function git(repo: string, args: string[]): string | undefined {
13 const run = Bun.spawnSync(["git", "-C", repo, ...args], {
14 env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: "/dev/null", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" },
15 });
16 return run.exitCode === 0 ? run.stdout.toString() : undefined;
17 }
18
19 export function verifyGit(repo: string, branch = "ledger", pinned?: string): { ok: true; entries: number; commits: number; key: string } | { ok: false; at: string; why: string } {
20 const ref = `refs/heads/${branch}`;
21 const commits = git(repo, ["rev-list", "--reverse", "--first-parent", ref]);
22 if (commits === undefined) return { ok: false, at: branch, why: "no such branch" };
23 const want = pinned?.trim().split(/\s+/).slice(0, 2).join(" ");
24 let key = want ?? "";
25 const oids = commits.split("\n").filter((l) => l !== "");
26 for (const oid of oids) {
27 const raw = git(repo, ["cat-file", "commit", oid]);
28 const split = raw === undefined ? undefined : splitCommit(raw);
29 if (split === undefined) return { ok: false, at: oid.slice(0, 12), why: "commit is not signed" };
30 const checked = checkMessage(split.signature, split.signed);
31 if (!checked.ok) return { ok: false, at: oid.slice(0, 12), why: `bad signature: ${checked.why}` };
32 if (key === "") key = checked.key;
33 else if (checked.key !== key) return { ok: false, at: oid.slice(0, 12), why: "signed by a different key than expected" };
34 }
35 const names = (git(repo, ["ls-tree", "--name-only", ref]) ?? "").split("\n").filter((n) => /^\d{4}-\d{2}\.jsonl$/.test(n));
36 const files = names.map((name) => ({ name, text: git(repo, ["show", `${ref}:${name}`]) ?? "" }));
37 const chain = verifyFiles(files);
38 if (!chain.ok) return chain;
39 return { ok: true, entries: chain.entries, commits: oids.length, key };
40 }
41
42 if (import.meta.main) {
43 const args = process.argv.slice(2);
44 const flag = (name: string): string | undefined => {
45 const i = args.indexOf(name);
46 return i === -1 ? undefined : args[i + 1];
47 };
48 const repo = flag("--git");
49 if (repo !== undefined) {
50 const verdict = verifyGit(repo, flag("--branch") ?? "ledger", flag("--key"));
51 if (verdict.ok) console.log(`ok ${verdict.entries} entries, ${verdict.commits} signed commits, key ${verdict.key}`);
52 else {
53 console.log(`broken at ${verdict.at}: ${verdict.why}`);
54 process.exit(1);
55 }
56 } else {
57 const dir = args[0] ?? join(process.env.FORGE_DATA ?? process.env.FORGE_APP_DATA ?? "data", "ledger");
58 const verdict = verifyDir(dir);
59 if (verdict.ok) console.log(`ok ${verdict.entries} entries`);
60 else {
61 console.log(`broken at ${verdict.at}: ${verdict.why}`);
62 process.exit(1);
63 }
64 }
1165 }
scripts/unused-exports-allow.json +1
@@ -33,6 +33,7 @@
3333 "src/core/issues.ts countIssues",
3434 "src/core/issues.ts issueRefKey",
3535 "src/core/issues.ts searchIssuesVisibleTo",
36 "src/core/ledger_git.ts resetBatchClock",
3637 "src/core/looks.ts isLookKind",
3738 "src/core/meter.ts PLACEMENTS",
3839 "src/core/oidc.ts rotateKeys",
src/core/ledger.ts +13 −4
@@ -140,15 +140,21 @@ export type Verdict = { ok: true; entries: number } | { ok: false; at: string; w
140140
141141 /** Walk every month in order; the first broken line is named with the reason. */
142142 export function verifyDir(d: string): Verdict {
143 if (!existsSync(d)) return { ok: true, entries: 0 };
144 return verifyFiles(months(d).map((name) => ({ name, text: readFileSync(join(d, name), "utf8") })));
145 }
146
147 /** The same walk over month files already in memory (a git tree, not a directory). */
148 export function verifyFiles(files: readonly { name: string; text: string }[]): Verdict {
143149 let prev = GENESIS;
144150 let n = 0;
145 if (!existsSync(d)) return { ok: true, entries: 0 };
146 for (const file of months(d)) {
147 const lines = readFileSync(join(d, file), "utf8").split("\n");
151 const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
152 for (const { name, text } of sorted) {
153 const lines = text.split("\n");
148154 for (let i = 0; i < lines.length; i++) {
149155 const line = lines[i] ?? "";
150156 if (line === "") continue;
151 const at = `${file}:${i + 1}`;
157 const at = `${name}:${i + 1}`;
152158 const entry = parseEntry(line);
153159 if (entry === undefined) return { ok: false, at, why: "not an entry" };
154160 if (entry.prev !== prev) return { ok: false, at, why: "prev is not the previous entry's hash" };
@@ -160,3 +166,6 @@ export function verifyDir(d: string): Verdict {
160166 }
161167 return { ok: true, entries: n };
162168 }
169
170 /** The month files of a ledger directory, oldest first. */
171 export const monthFiles = months;
src/core/ledger_git.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The cost ledger's chain, committed to git (#12113). Git is truth; the jsonl
3 * under `<data>/ledger` is the spool. Each batch appends the new lines to the
4 * month files on the `ledger` branch of a repository the forge owns and seals
5 * them in ONE commit signed with the forge's SSH host key, so another forge
6 * can clone the branch and check the chain and the signatures with no
7 * database (`scripts/ledger-verify.ts --git`).
8 *
9 * Entry text only ever reaches git as blob content through `commitFiles`'
10 * plumbing: it is never an argument, a path or a message.
11 */
12
13 import { readFileSync } from "node:fs";
14 import { join } from "node:path";
15 import type { App } from "../app.ts";
16 import { getRepo } from "./repos.ts";
17 import { repoDir } from "../git/repos.ts";
18 import { runGitRaw } from "../git/env.ts";
19 import { commitFiles } from "../git/write.ts";
20 import { loadOrCreateHostKey, type HostKey } from "../ssh/keys.ts";
21 import { signMessage } from "../ssh/sshsig.ts";
22 import { monthFiles } from "./ledger.ts";
23 import { ok, type Result } from "../kernel/result.ts";
24
25 export const LEDGER_BRANCH = "ledger";
26 export const BATCH_EVERY_MS = 3_600_000;
27 const AUTHOR = { name: "forge ledger", email: "[email protected]" };
28
29 export type Batch = { commit: string; entries: number } | { commit: undefined; entries: 0 };
30
31 const lines = (text: string): number => text.split("\n").filter((l) => l !== "").length;
32
33 /** The month file as the branch holds it; empty when the branch or file is not there yet. */
34 async function inGit(dir: string, name: string): Promise<string> {
35 const read = await runGitRaw(["cat-file", "blob", `refs/heads/${LEDGER_BRANCH}:${name}`], { cwd: dir });
36 return read.ok ? new TextDecoder().decode(read.value) : "";
37 }
38
39 /**
40 * Commit what the spool has that the branch does not: one commit, every
41 * month file that grew. A spool the branch is not a prefix of was rewritten
42 * or lost: nothing is committed over it, and the failure says which file.
43 */
44 export async function commitBatch(input: {
45 ledgerDir: string;
46 repoDir: string;
47 key: HostKey;
48 }): Promise<Result<Batch, string>> {
49 const files: { path: string; content: string }[] = [];
50 let entries = 0;
51 for (const name of monthFiles(input.ledgerDir)) {
52 const raw = readFileSync(join(input.ledgerDir, name), "utf8");
53 // Whole lines only: a torn last line waits for the next batch.
54 const spool = raw.slice(0, raw.lastIndexOf("\n") + 1);
55 const held = await inGit(input.repoDir, name);
56 if (spool === held) continue;
57 if (!spool.startsWith(held)) return { ok: false, error: `${name}: the branch holds lines the spool does not` };
58 files.push({ path: name, content: spool });
59 entries += lines(spool) - lines(held);
60 }
61 if (files.length === 0) return ok({ commit: undefined, entries: 0 });
62 const made = await commitFiles(input.repoDir, {
63 branch: LEDGER_BRANCH,
64 message: `ledger: ${entries} ${entries === 1 ? "entry" : "entries"}\n`,
65 files,
66 author: AUTHOR,
67 sign: (commit) => signMessage(input.key, commit),
68 });
69 if (!made.ok) return { ok: false, error: `commit failed: ${made.error.kind}` };
70 return ok({ commit: made.value.commitOid, entries });
71 }
72
73 let lastAt = 0;
74 export const resetBatchClock = (): void => {
75 lastAt = 0;
76 };
77
78 /** The timer's work: at most once an hour, and a commit only when there is something new. */
79 export async function ledgerGitTick(app: App, ledgerDir: string, now = Date.now()): Promise<void> {
80 if (lastAt !== 0 && now - lastAt < BATCH_EVERY_MS) return;
81 const [owner = "", name = ""] = (process.env.FORGE_LEDGER_REPO ?? app.config.forgeRepo ?? "").split("/");
82 if (owner === "" || name === "") return;
83 const repo = getRepo(app.db, owner, name);
84 if (repo === undefined) return;
85 lastAt = now;
86 const done = await commitBatch({
87 ledgerDir,
88 repoDir: repoDir(app.reposRoot, repo.ownerName, repo.name),
89 key: loadOrCreateHostKey(join(app.config.dataDir, "ssh")),
90 });
91 if (!done.ok) app.log.warn("ledger batch failed", { error: done.error });
92 else if (done.value.commit !== undefined)
93 app.log.info("ledger batch committed", { commit: done.value.commit, entries: done.value.entries });
94 }
src/git/write.ts +26 −1
@@ -45,6 +45,16 @@ export interface CommitResult {
4545 readonly created: boolean;
4646 }
4747
48 /**
49 * A commit object with a `gpgsig` header after its committer line: continuation lines start
50 * with a space.
51 */
52 export function withSignature(commit: string, sign: (commit: string) => string): string {
53 const at = commit.indexOf("\n\n");
54 const signature = sign(commit).trimEnd().split("\n").join("\n ");
55 return `${commit.slice(0, at)}\ngpgsig ${signature}${commit.slice(at)}`;
56 }
57
4858 export async function commitFiles(
4959 dir: string,
5060 input: {
@@ -63,6 +73,11 @@ export async function commitFiles(
6373 /** Refuse, writing nothing, unless the branch is at this commit now. */
6474 expectTip?: string;
6575 author: { name: string; email: string };
76 /**
77 * Signs the commit: given the commit's bytes, returns an armored
78 * signature, which becomes its `gpgsig` header (`signCommit`).
79 */
80 sign?: (commit: string) => string;
6681 /**
6782 * Called with the sealed commit *before* the local ref moves. An error
6883 * aborts the landing: the objects stay written but unreferenced (git gc
@@ -206,7 +221,17 @@ export async function commitFiles(
206221 { cwd: dir, env: commitEnv },
207222 );
208223 if (!commit.ok) return err({ kind: "git", message: commit.error.stderr });
209 const commitOid = commit.value.trim();
224 let commitOid = commit.value.trim();
225 if (input.sign !== undefined) {
226 const raw = await runGit(["cat-file", "commit", commitOid], { cwd: dir });
227 if (!raw.ok) return err({ kind: "git", message: raw.error.stderr });
228 const signed = await runGit(["hash-object", "-t", "commit", "-w", "--stdin"], {
229 cwd: dir,
230 stdin: withSignature(`${raw.value}\n`, input.sign),
231 });
232 if (!signed.ok) return err({ kind: "git", message: signed.error.stderr });
233 commitOid = signed.value.trim();
234 }
210235
211236 // Whoever has the last word gets it here, while the commit exists but
212237 // nothing points at it yet.
src/main.ts +10
@@ -9,6 +9,7 @@ import { sweepEntitlements } from "./billing/sync.ts";
99 import { scanNightly } from "./checks/scan.ts";
1010 import { nightlyDue } from "./checks/scan_store.ts";
1111 import { setLedgerDir } from "./core/ledger.ts";
12 import { BATCH_EVERY_MS, ledgerGitTick } from "./core/ledger_git.ts";
1213 import { getRepoById } from "./core/repos.ts";
1314 import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts";
1415 import { startMemorySampler } from "./boards/memory.ts";
@@ -792,6 +793,14 @@ const nightlyScanTimer = setInterval(() => {
792793 }
793794 }).catch((error) => app.log.warn("nightly scan failed", { error: String(error) }));
794795 }, 3_600_000);
796 // The cost ledger's chain goes to the `ledger` branch in signed batches: once an hour, and only
797 // when there is something new (core/ledger_git.ts).
798 const ledgerGitTimer = readOnly
799 ? undefined
800 : setInterval(
801 () => void during("ledger-git", () => ledgerGitTick(app, join(dataDir, "ledger"))).catch((error) => app.log.warn("ledger batch failed", { error: String(error) })),
802 BATCH_EVERY_MS,
803 );
795804 const processTimer = setInterval(() => during("processes", processTick), 60_000);
796805 during("processes", processTick);
797806 // A new version proves itself against the public URL; a failed one again, 5 min after and backing off to hourly (release/prove.ts).
@@ -947,6 +956,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
947956 if (observedTimer !== undefined) clearInterval(observedTimer);
948957 const engineStopped = engine?.stop();
949958 clearInterval(nightlyScanTimer);
959 if (ledgerGitTimer !== undefined) clearInterval(ledgerGitTimer);
950960 clearInterval(heartbeatTimer);
951961 clearInterval(upstreamTimer);
952962 clearInterval(reachTimer);
src/ssh/sshsig.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * OpenSSH signatures over a message (the `SSHSIG` format, PROTOCOL.sshsig),
3 * the kind git keeps in a commit's `gpgsig` header when `gpg.format=ssh`.
4 * Ed25519 only, namespace `git`, sha512: what the forge signs with, and what
5 * `git verify-commit` accepts from this key. No `ssh-keygen` is needed to make
6 * or check one.
7 */
8
9 import { createHash, sign as cryptoSign } from "node:crypto";
10 import { verifySshSignature, type HostKey } from "./keys.ts";
11 import { Reader, Writer } from "./wire.ts";
12
13 const MAGIC = "SSHSIG";
14 const BEGIN = "-----BEGIN SSH SIGNATURE-----";
15 const END = "-----END SSH SIGNATURE-----";
16
17 /** What is actually signed: the magic, then namespace, reserved, hash name, the message's hash. */
18 function signedData(namespace: string, message: string): Uint8Array {
19 const digest = createHash("sha512").update(message).digest();
20 const body = new Writer(256).string(namespace).string("").string("sha512").string(digest).build();
21 return Buffer.concat([Buffer.from(MAGIC), body]);
22 }
23
24 /** An armored signature of `message`, as git stores it. */
25 export function signMessage(key: HostKey, message: string, namespace = "git"): string {
26 const raw = cryptoSign(null, signedData(namespace, message), key.privateKey);
27 const sig = new Writer(128).string("ssh-ed25519").string(raw).build();
28 const blob = new Writer(512)
29 .raw(Buffer.from(MAGIC))
30 .u32(1)
31 .string(key.publicKey.blob)
32 .string(namespace)
33 .string("")
34 .string("sha512")
35 .string(sig)
36 .build();
37 const b64 = Buffer.from(blob).toString("base64");
38 const lines = b64.match(/.{1,70}/g) ?? [];
39 return `${BEGIN}\n${lines.join("\n")}\n${END}\n`;
40 }
41
42 export type Checked = { ok: true; fingerprint: string; key: string } | { ok: false; why: string };
43
44 /** Check an armored signature over `message`; on success, whose key made it. */
45 export function checkMessage(armored: string, message: string, namespace = "git"): Checked {
46 try {
47 const lines = armored.trim().split("\n").map((l) => l.trim());
48 if (lines[0] !== BEGIN || lines[lines.length - 1] !== END) return { ok: false, why: "not an SSH signature" };
49 const blob = new Uint8Array(Buffer.from(lines.slice(1, -1).join(""), "base64"));
50 const r = new Reader(blob);
51 if (Buffer.from(r.bytes(6)).toString() !== MAGIC) return { ok: false, why: "not an SSH signature" };
52 if (r.u32() !== 1) return { ok: false, why: "unknown signature version" };
53 const keyBlob = r.string();
54 if (r.text() !== namespace) return { ok: false, why: "wrong signature namespace" };
55 r.string();
56 if (r.text() !== "sha512") return { ok: false, why: "unsupported hash" };
57 const sig = r.string();
58 r.end();
59 const verified = verifySshSignature("ssh-ed25519", keyBlob, sig, signedData(namespace, message));
60 if (!verified.ok) return { ok: false, why: verified.error };
61 const key = `ssh-ed25519 ${Buffer.from(keyBlob).toString("base64")}`;
62 const fingerprint = `SHA256:${createHash("sha256").update(keyBlob).digest("base64").replace(/=+$/, "")}`;
63 return { ok: true, fingerprint, key };
64 } catch (cause) {
65 return { ok: false, why: `unreadable signature: ${(cause as Error).message}` };
66 }
67 }
68
69 /** Split a commit object into its `gpgsig` header signature and the bytes that were signed. */
70 export function splitCommit(commit: string): { signature: string; signed: string } | undefined {
71 const at = commit.indexOf("\n\n");
72 const head = commit.slice(0, at === -1 ? commit.length : at).split("\n");
73 const rest = at === -1 ? "" : commit.slice(at);
74 const kept: string[] = [];
75 const sig: string[] = [];
76 let inSig = false;
77 for (const line of head) {
78 if (line.startsWith("gpgsig ")) {
79 inSig = true;
80 sig.push(line.slice(7));
81 } else if (inSig && line.startsWith(" ")) sig.push(line.slice(1));
82 else {
83 inSig = false;
84 kept.push(line);
85 }
86 }
87 return sig.length === 0 ? undefined : { signature: sig.join("\n"), signed: kept.join("\n") + rest };
88 }
test/job-slices.test.ts +1
@@ -250,6 +250,7 @@ const UNTIMED: Record<string, string> = {
250250 probes: "probes each app over HTTP after a deploy and nightly; the fixture has no apps",
251251 prompts: "a closure in startPromptRoutine; the fixture has no prompt routines",
252252 timers: "fires due app timers over HTTP to the apps; the fixture has no apps",
253 "ledger-git": "commits the cost ledger chain to the ledger branch at most hourly: a git commit and signature in a data directory; the fixture has no ledger directory, and test/ledger-git.test.ts runs the tick",
253254 "run-log-flush": "a one-shot timer in runs/log.ts that appends buffered lines to the run log files: I/O, no fixture runs",
254255 "spawn-restart": "a one-shot backoff timer in spawn/stopped.ts that restarts a stopped spawn; the fixture has no spawns",
255256 "origin-broker-sweep": "a one-shot timer a minute after a brokered sign-in: one DELETE of its spent and expired rows; the fixture brokers none",
test/ledger-git.test.ts added +138
@@ -0,0 +1,138 @@
1 import { expect, test } from "bun:test";
2 import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { GENESIS, hashOf } from "../src/core/ledger.ts";
8 import { BATCH_EVERY_MS, commitBatch, ledgerGitTick, resetBatchClock } from "../src/core/ledger_git.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { runGit } from "../src/git/env.ts";
12 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
13 import { verifyGit } from "../scripts/ledger-verify.ts";
14
15 const tmp = (p: string): string => mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), p));
16
17 async function setup(): Promise<{ app: App; repo: string; spool: string; key: ReturnType<typeof loadOrCreateHostKey> }> {
18 const app = createApp({ dataDir: tmp("lg-app-"), port: 0 });
19 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
20 if (!a.ok) throw new Error(a.error);
21 const made = await createRepo(app, a.value, { name: "r" });
22 if (!made.ok) throw new Error(String(made.error));
23 const spool = join(tmp("lg-spool-"), "ledger");
24 mkdirSync(spool, { recursive: true });
25 return { app, repo: repoDir(app.reposRoot, "alice", "r"), spool, key: loadOrCreateHostKey(tmp("lg-key-")) };
26 }
27
28 let tip = GENESIS;
29 let n = 0;
30 function entry(): string {
31 const body = { run: `run-${n++}`, kind: "check", repo: "r", endedAt: "2026-11-13T10:00:00.000Z", prev: tip };
32 tip = hashOf(body);
33 return `${JSON.stringify({ ...body, hash: tip })}\n`;
34 }
35 const count = async (repo: string): Promise<number> => {
36 const r = await runGit(["rev-list", "--count", "refs/heads/ledger"], { cwd: repo });
37 return r.ok ? Number(r.value) : 0;
38 };
39
40 test("a batch makes one signed commit; no new entries make none", async () => {
41 tip = GENESIS;
42 n = 0;
43 const { repo, spool, key } = await setup();
44 writeFileSync(join(spool, "2026-10.jsonl"), entry());
45 appendFileSync(join(spool, "2026-10.jsonl"), entry());
46 writeFileSync(join(spool, "2026-11.jsonl"), entry());
47 const first = await commitBatch({ ledgerDir: spool, repoDir: repo, key });
48 expect(first.ok && first.value.entries).toBe(3);
49 expect(await count(repo)).toBe(1);
50
51 const again = await commitBatch({ ledgerDir: spool, repoDir: repo, key });
52 expect(again.ok && again.value.commit).toBeUndefined();
53 expect(await count(repo)).toBe(1);
54
55 appendFileSync(join(spool, "2026-11.jsonl"), entry());
56 const second = await commitBatch({ ledgerDir: spool, repoDir: repo, key });
57 expect(second.ok && second.value.entries).toBe(1);
58 expect(await count(repo)).toBe(2);
59
60 const verdict = verifyGit(repo);
61 expect(verdict).toMatchObject({ ok: true, entries: 4, commits: 2, key: key.publicKey.line });
62 expect(verifyGit(repo, "ledger", key.publicKey.line).ok).toBe(true);
63 const other = loadOrCreateHostKey(tmp("lg-other-"));
64 expect(verifyGit(repo, "ledger", other.publicKey.line).ok).toBe(false);
65 });
66
67 test("a rewritten entry in the git file breaks verification at that line", async () => {
68 tip = GENESIS;
69 n = 0;
70 const { repo, spool, key } = await setup();
71 const text = entry() + entry() + entry();
72 writeFileSync(join(spool, "2026-11.jsonl"), text);
73 await commitBatch({ ledgerDir: spool, repoDir: repo, key });
74 // The forge's own key signs a commit that rewrites line 2: the chain still names it.
75 const [a = "", b = "", c = ""] = text.split("\n");
76 writeFileSync(join(spool, "2026-11.jsonl"), `${a}\n${b.replace("run-1", "run-X")}\n${c}\n`);
77 const spoolRewrite = await commitBatch({ ledgerDir: spool, repoDir: repo, key });
78 expect(spoolRewrite.ok).toBe(false); // the spool is not an append of what git holds: nothing is committed
79 expect(await count(repo)).toBe(1);
80 // Someone edits the file inside git itself, with a valid signature of their own: the pinned
81 // key refuses.
82 const blob = await runGit(["hash-object", "-w", "--stdin"], { cwd: repo, stdin: `${a}\n${b.replace("run-1", "run-X")}\n${c}\n` });
83 if (!blob.ok) throw new Error(blob.error.stderr);
84 const tree = await runGit(["mktree"], { cwd: repo, stdin: `100644 blob ${blob.value}\t2026-11.jsonl\n` });
85 if (!tree.ok) throw new Error(tree.error.stderr);
86 const env = { GIT_AUTHOR_NAME: "x", GIT_AUTHOR_EMAIL: "x@x", GIT_COMMITTER_NAME: "x", GIT_COMMITTER_EMAIL: "x@x" };
87 const parent = await runGit(["rev-parse", "refs/heads/ledger"], { cwd: repo });
88 if (!parent.ok) throw new Error("no tip");
89 const { signMessage } = await import("../src/ssh/sshsig.ts");
90 const { withSignature } = await import("../src/git/write.ts");
91 const raw = await runGit(["commit-tree", tree.value, "-p", parent.value, "-m", "edit"], { cwd: repo, env });
92 if (!raw.ok) throw new Error(raw.error.stderr);
93 const body = await runGit(["cat-file", "commit", raw.value], { cwd: repo });
94 if (!body.ok) throw new Error("cat");
95 const signed = await runGit(["hash-object", "-t", "commit", "-w", "--stdin"], { cwd: repo, stdin: withSignature(`${body.value}\n`, (c) => signMessage(key, c)) });
96 if (!signed.ok) throw new Error(signed.error.stderr);
97 await runGit(["update-ref", "refs/heads/ledger", signed.value], { cwd: repo });
98 const verdict = verifyGit(repo);
99 expect(verdict).toEqual({ ok: false, at: "2026-11.jsonl:2", why: "hash does not match the entry" });
100 });
101
102 test("an unsigned or tampered commit fails verification", async () => {
103 tip = GENESIS;
104 n = 0;
105 const { repo, spool, key } = await setup();
106 writeFileSync(join(spool, "2026-11.jsonl"), entry());
107 await commitBatch({ ledgerDir: spool, repoDir: repo, key });
108 const head = await runGit(["cat-file", "commit", "refs/heads/ledger"], { cwd: repo });
109 expect(head.ok && head.value.includes("gpgsig -----BEGIN SSH SIGNATURE-----")).toBe(true);
110 // Changing the message after signing breaks the signature.
111 const forged = head.ok ? head.value.replace("ledger: 1 entry", "ledger: 9 entries") : "";
112 const oid = await runGit(["hash-object", "-t", "commit", "-w", "--stdin"], { cwd: repo, stdin: `${forged}\n` });
113 if (!oid.ok) throw new Error(oid.error.stderr);
114 await runGit(["update-ref", "refs/heads/ledger", oid.value], { cwd: repo });
115 const v = verifyGit(repo);
116 expect(v.ok).toBe(false);
117 expect(!v.ok && v.why).toContain("bad signature");
118 });
119
120 test("the timer's tick batches at most once an hour, and only for new entries", async () => {
121 tip = GENESIS;
122 n = 0;
123 resetBatchClock();
124 const { app, repo, spool } = await setup();
125 app.config.forgeRepo = "alice/r";
126 writeFileSync(join(spool, "2026-11.jsonl"), entry());
127 const t0 = 1_000_000;
128 await ledgerGitTick(app, spool, t0);
129 expect(await count(repo)).toBe(1);
130 appendFileSync(join(spool, "2026-11.jsonl"), entry());
131 await ledgerGitTick(app, spool, t0 + 60_000);
132 expect(await count(repo)).toBe(1);
133 await ledgerGitTick(app, spool, t0 + BATCH_EVERY_MS);
134 expect(await count(repo)).toBe(2);
135 await ledgerGitTick(app, spool, t0 + 2 * BATCH_EVERY_MS);
136 expect(await count(repo)).toBe(2);
137 expect(readFileSync(join(spool, "2026-11.jsonl"), "utf8").split("\n").length).toBe(3);
138 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs