1// CI attestations: signed statements stored as git notes under refs/notes/ci.2//3// A note holds one or more blocks. Each block is a statement followed by an4// armored SSH signature (ssh-keygen -Y sign -n forge-ci) over the statement5// bytes. Log lines are prefixed with "|" so they can never forge a header or6// a signature boundary. See `forge attest` for the writer.7import { spawnSync } from 'node:child_process';8import { createHash } from 'node:crypto';9import fs from 'node:fs';10import os from 'node:os';11import path from 'node:path';12import type { Attestation, CiStatus, Metric, Verification } from './types.ts';1314export const NAMESPACE = 'forge-ci';15const BLOCK = /^forge-attestation 1\n[\s\S]*?^-----END SSH SIGNATURE-----$/gm;1617export function parseNote(text: string): Attestation[] {18 const out: Attestation[] = [];19 for (const m of text.matchAll(BLOCK)) {20 const block = m[0];21 const at = block.search(/^-----BEGIN SSH SIGNATURE-----$/m);22 if (at < 0) continue;23 const payload = block.slice(0, at);24 const fields: Record<string, string> = {};25 const log: string[] = [];26 const metrics: Metric[] = [];27 for (const line of payload.split('\n').slice(1)) {28 if (line.startsWith('|')) {29 log.push(line.slice(1));30 continue;31 }32 // metric <name> <number> [<unit>], as forge attest writes them.33 const m = /^metric ([a-z][a-z0-9_.-]{0,63}) (\S+)(?: ([A-Za-z%/]{1,16}))?$/.exec(line);34 if (m) {35 const value = Number(m[2]);36 if (Number.isFinite(value) && !metrics.some((x) => x.name === m[1])) metrics.push({ name: m[1], value, unit: m[3] ?? null });37 continue;38 }39 const sp = line.indexOf(' ');40 if (sp > 0) fields[line.slice(0, sp)] = line.slice(sp + 1);41 }42 out.push({43 payload,44 signature: block.slice(at) + '\n',45 commit: fields.commit,46 tree: fields.tree,47 result: fields.result,48 exit: fields.exit,49 started: fields.started,50 duration: fields.duration,51 runner: fields.runner,52 system: fields.system,53 env: fields.env,54 script: fields.script,55 logHash: fields.log,56 log,57 metrics,58 verification: { ok: false, signer: null, reason: 'not checked' },59 });60 }61 return out;62}6364// Verifies statements against an allowed_signers file, memoized on disk by65// statement hash (statements and signatures are immutable).66// The namespace is what the statements were signed for: forge-ci for CI67// attestations, forge-review for agent reviews (lib/critique.ts).68export class Verifier {69 allowedSigners: string | null;70 cacheFile: string | null;71 namespace: string;72 cache: Record<string, Verification> = {};73 dirty = false;7475 constructor({ allowedSigners, cacheFile, namespace = NAMESPACE }: { allowedSigners: string | null; cacheFile: string | null; namespace?: string }) {76 this.allowedSigners = allowedSigners && fs.existsSync(allowedSigners) ? allowedSigners : null;77 this.cacheFile = cacheFile;78 this.namespace = namespace;79 if (cacheFile && fs.existsSync(cacheFile)) {80 try {81 this.cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));82 } catch {83 this.cache = {};84 }85 }86 }8788 verify(att: { payload: string; signature: string }): Verification {89 if (!this.allowedSigners) return { ok: false, signer: null, reason: 'no allowed_signers configured' };90 const key = createHash('sha256').update(this.allowedSigners + '\0' + fs.statSync(this.allowedSigners).mtimeMs + '\0' + this.namespace + '\0' + att.payload + att.signature).digest('hex');91 const hit = this.cache[key];92 if (hit) return hit;93 const res = this.check(att, this.allowedSigners);94 this.cache[key] = res;95 this.dirty = true;96 return res;97 }9899 private check(att: { payload: string; signature: string }, signers: string): Verification {100 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-att-'));101 try {102 const sig = path.join(dir, 'sig');103 fs.writeFileSync(sig, att.signature);104 const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', sig, '-f', signers], { encoding: 'utf8' });105 const principal = find.status === 0 ? find.stdout.split('\n')[0].trim() : null;106 if (!principal) return { ok: false, signer: null, reason: 'signing key not in allowed_signers' };107 const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', principal, '-n', this.namespace, '-s', sig], { input: att.payload, encoding: 'utf8' });108 if (v.status !== 0) return { ok: false, signer: principal, reason: 'bad signature' };109 return { ok: true, signer: principal, key: /key (\S+ SHA256:\S+)/.exec(v.stdout + v.stderr)?.[1] ?? null };110 } finally {111 fs.rmSync(dir, { recursive: true, force: true });112 }113 }114115 save(): void {116 if (!this.dirty || !this.cacheFile) return;117 fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });118 fs.writeFileSync(this.cacheFile, JSON.stringify(this.cache));119 }120}121122// Summarize attestations that apply to one tree. Attestations are keyed by123// tree, so a reworded or re-parented commit with an identical tree keeps its124// CI result without re-running.125export function ciStatus(list: Attestation[] | undefined): CiStatus | null {126 if (!list?.length) return null;127 const verified = list.filter((a) => a.verification.ok);128 const pool = verified.length ? verified : list;129 const latest = [...pool].sort((a, b) => String(b.started).localeCompare(String(a.started)))[0];130 const state = !latest.verification.ok ? 'unverified' : latest.result === 'pass' ? 'pass' : 'fail';131 return { state, latest, count: list.length };132}