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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12113-cost-ledger-the-chain-is-committed-to-gi → main 3 commits fork
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 | ||
| 19 | 19 | Prints `ok N entries`, or `broken at <file>:<line>: <why>` and exits 1. |
| 20 | 20 | |
| 21 | 21 | 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 | */ | |
| 2 | 8 | 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"; | |
| 4 | 11 | |
| 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 | } | |
| 11 | 65 | } |
scripts/unused-exports-allow.json +1
| @@ -33,6 +33,7 @@ | ||
| 33 | 33 | "src/core/issues.ts countIssues", |
| 34 | 34 | "src/core/issues.ts issueRefKey", |
| 35 | 35 | "src/core/issues.ts searchIssuesVisibleTo", |
| 36 | "src/core/ledger_git.ts resetBatchClock", | |
| 36 | 37 | "src/core/looks.ts isLookKind", |
| 37 | 38 | "src/core/meter.ts PLACEMENTS", |
| 38 | 39 | "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 | ||
| 140 | 140 | |
| 141 | 141 | /** Walk every month in order; the first broken line is named with the reason. */ |
| 142 | 142 | 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 { | |
| 143 | 149 | let prev = GENESIS; |
| 144 | 150 | 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"); | |
| 148 | 154 | for (let i = 0; i < lines.length; i++) { |
| 149 | 155 | const line = lines[i] ?? ""; |
| 150 | 156 | if (line === "") continue; |
| 151 | const at = `${file}:${i + 1}`; | |
| 157 | const at = `${name}:${i + 1}`; | |
| 152 | 158 | const entry = parseEntry(line); |
| 153 | 159 | if (entry === undefined) return { ok: false, at, why: "not an entry" }; |
| 154 | 160 | 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 { | ||
| 160 | 166 | } |
| 161 | 167 | return { ok: true, entries: n }; |
| 162 | 168 | } |
| 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 { | ||
| 45 | 45 | readonly created: boolean; |
| 46 | 46 | } |
| 47 | 47 | |
| 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 | ||
| 48 | 58 | export async function commitFiles( |
| 49 | 59 | dir: string, |
| 50 | 60 | input: { |
| @@ -63,6 +73,11 @@ export async function commitFiles( | ||
| 63 | 73 | /** Refuse, writing nothing, unless the branch is at this commit now. */ |
| 64 | 74 | expectTip?: string; |
| 65 | 75 | 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; | |
| 66 | 81 | /** |
| 67 | 82 | * Called with the sealed commit *before* the local ref moves. An error |
| 68 | 83 | * aborts the landing: the objects stay written but unreferenced (git gc |
| @@ -206,7 +221,17 @@ export async function commitFiles( | ||
| 206 | 221 | { cwd: dir, env: commitEnv }, |
| 207 | 222 | ); |
| 208 | 223 | 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 | } | |
| 210 | 235 | |
| 211 | 236 | // Whoever has the last word gets it here, while the commit exists but |
| 212 | 237 | // nothing points at it yet. |
src/main.ts +10
| @@ -9,6 +9,7 @@ import { sweepEntitlements } from "./billing/sync.ts"; | ||
| 9 | 9 | import { scanNightly } from "./checks/scan.ts"; |
| 10 | 10 | import { nightlyDue } from "./checks/scan_store.ts"; |
| 11 | 11 | import { setLedgerDir } from "./core/ledger.ts"; |
| 12 | import { BATCH_EVERY_MS, ledgerGitTick } from "./core/ledger_git.ts"; | |
| 12 | 13 | import { getRepoById } from "./core/repos.ts"; |
| 13 | 14 | import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts"; |
| 14 | 15 | import { startMemorySampler } from "./boards/memory.ts"; |
| @@ -792,6 +793,14 @@ const nightlyScanTimer = setInterval(() => { | ||
| 792 | 793 | } |
| 793 | 794 | }).catch((error) => app.log.warn("nightly scan failed", { error: String(error) })); |
| 794 | 795 | }, 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 | ); | |
| 795 | 804 | const processTimer = setInterval(() => during("processes", processTick), 60_000); |
| 796 | 805 | during("processes", processTick); |
| 797 | 806 | // 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> { | ||
| 947 | 956 | if (observedTimer !== undefined) clearInterval(observedTimer); |
| 948 | 957 | const engineStopped = engine?.stop(); |
| 949 | 958 | clearInterval(nightlyScanTimer); |
| 959 | if (ledgerGitTimer !== undefined) clearInterval(ledgerGitTimer); | |
| 950 | 960 | clearInterval(heartbeatTimer); |
| 951 | 961 | clearInterval(upstreamTimer); |
| 952 | 962 | 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> = { | ||
| 250 | 250 | probes: "probes each app over HTTP after a deploy and nightly; the fixture has no apps", |
| 251 | 251 | prompts: "a closure in startPromptRoutine; the fixture has no prompt routines", |
| 252 | 252 | 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", | |
| 253 | 254 | "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", |
| 254 | 255 | "spawn-restart": "a one-shot backoff timer in spawn/stopped.ts that restarts a stopped spawn; the fixture has no spawns", |
| 255 | 256 | "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 | }); |
new above
offline