/** * @file runtime/src/rae1/chain_gate.ts * @description RAE-1 SHA-256 hash chain verification and integrity enforcement. * * Validates JSONL receipt chains for RAE-1 protocol compliance. * Each line is a DSSE-signed envelope; the payload's prev_hash field * must equal the SHA-256 of the prior line (exact bytes, compact JSON). * * Lean ref: SZL.AGI.PACBayes.capability_improvement_rate_bound * Lean file: Lutar/PACBayes/CapabilityImprovementRate.lean * Lean commit: c4d1379568 * * Protocol spec: RAE_1_PROTOCOL.md §4 * * Chain linkage formula: * hash(R_i) = SHA-256(raw_json_line_i) (UTF-8, compact, no trailing newline) * R_{i+1}.payload.prev_hash = hex(hash(R_i)) * R_0.payload.prev_hash = "GENESIS" * * Doctrine v7 — real code, no fake green. * Signed-off-by: SZL Engineering */ import { createHash } from "crypto"; import { CHAIN_GENESIS } from "./schema.js"; import type { DSSEEnvelope, RAE1Payload } from "./schema.js"; // ─── Types ──────────────────────────────────────────────────────────────────── /** * Result of validating a complete JSONL receipt chain. * * Lean ref: SZL.AGI.PACBayes.capability_improvement_rate_bound * commit: c4d1379568 */ export interface ChainValidationResult { /** True iff all receipts form a valid, unbroken SHA-256 chain. */ valid: boolean; /** Number of receipts processed. */ n_receipts: number; /** * The genesis sentinel for this chain. Always "GENESIS" for RAE-1 v1.0 * (a future extension may support run_id-keyed genesis hashes). */ chain_root: string; /** SHA-256 hex of the last receipt line — the published verification handle. */ chain_head: string; /** Number of receipts where is_solved === true. */ n_solved: number; /** * Score as a fraction in [0, 1]. * Equals n_solved / n_receipts if n_receipts > 0, else 0. */ score_01: number; /** List of validation errors in order. Empty when valid === true. */ errors: string[]; } /** * Lightweight result for computing only the chain head without full validation. */ export interface ChainHeadResult { /** SHA-256 hex of the final receipt line. */ chain_head: string; /** Number of lines processed. */ n_lines: number; } // ─── Core Primitives ───────────────────────────────────────────────────────── /** * Computes the SHA-256 hex digest of a single receipt line (raw UTF-8 bytes). * * This is the canonical hash used in the `prev_hash` field of the next receipt. * * IMPORTANT: The input MUST be the exact raw JSON line as it appears in the JSONL file * (compact, no trailing newline or extra whitespace). Any whitespace difference will * produce a different hash and break the chain. * * Lean ref: SZL.AGI.PACBayes.capability_improvement_rate_bound * commit: c4d1379568 * * @param line - Raw JSON line string (UTF-8, compact, no trailing newline) * @returns 64-character lowercase hex SHA-256 digest */ export function computeLineHash(line: string): string { return createHash("sha256").update(line, "utf8").digest("hex"); } /** * Decodes a base64url payload string from a DSSE envelope into a RAE1Payload. * * Does not validate the payload — use validateRAE1Schema() for full validation. * * @param envelope - DSSE envelope containing base64url-encoded payload * @returns Parsed RAE1Payload * @throws If the payload is not valid base64url JSON */ export function decodeEnvelopePayload(envelope: DSSEEnvelope): RAE1Payload { const raw = Buffer.from(envelope.payload, "base64url").toString("utf8"); return JSON.parse(raw) as RAE1Payload; } // ─── Chain Validation ───────────────────────────────────────────────────────── /** * Validates the SHA-256 hash chain over a complete JSONL receipt file. * * Algorithm (per RAE_1_PROTOCOL.md §4.1): * 1. Split content by newlines, filter blank lines. * 2. For each line i: * a. Parse as JSON → DSSEEnvelope * b. Base64url-decode payload → RAE1Payload * c. Assert payload.prev_hash === expectedPrevHash * d. Assert payload.receipt_index === i * e. Update expectedPrevHash = SHA-256(line_i) * 3. Return chain_head = expectedPrevHash after final receipt. * * Lean ref: SZL.AGI.PACBayes.capability_improvement_rate_bound * file: Lutar/PACBayes/CapabilityImprovementRate.lean * commit: c4d1379568 * * @param jsonlContent - Full content of a receipts.jsonl file as a string * @returns ChainValidationResult with validity flag, head hash, score, and errors * * @example * ```typescript * import { readFileSync } from "fs"; * const content = readFileSync("receipts.jsonl", "utf8"); * const result = validateReceiptChain(content); * if (result.valid) { * console.log("Chain valid. Head:", result.chain_head, "Score:", result.score_01); * } else { * console.error("Chain broken:", result.errors); * } * ``` */ export function validateReceiptChain(jsonlContent: string): ChainValidationResult { const lines = jsonlContent.split("\n").filter((l) => l.trim().length > 0); const errors: string[] = []; let prevHash = CHAIN_GENESIS; let chainRoot = CHAIN_GENESIS; let nSolved = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Step 1: Parse outer DSSE envelope let envelope: Record; try { envelope = JSON.parse(line) as Record; } catch (e) { errors.push(`Line ${i}: JSON parse error — ${(e as Error).message}`); // Can't compute chain hash without valid JSON; advance with synthetic hash to continue checking prevHash = computeLineHash(line); continue; } // Step 2: Decode inner payload let payload: Record; try { if (typeof envelope.payload !== "string") { throw new Error("envelope.payload is not a string"); } const raw = Buffer.from(envelope.payload, "base64url").toString("utf8"); payload = JSON.parse(raw) as Record; } catch (e) { errors.push(`Line ${i}: payload decode error — ${(e as Error).message}`); prevHash = computeLineHash(line); continue; } // Step 3: Track chain root (for first receipt, prev_hash should be GENESIS) if (i === 0) { chainRoot = typeof payload.prev_hash === "string" ? payload.prev_hash : CHAIN_GENESIS; } // Step 4: Verify prev_hash linkage const claimedPrevHash = payload.prev_hash as string; if (claimedPrevHash !== prevHash) { errors.push( `Line ${i} (receipt_index=${payload.receipt_index}): ` + `prev_hash mismatch — expected "${prevHash}", got "${claimedPrevHash}"` ); } // Step 5: Verify receipt_index if (payload.receipt_index !== i) { errors.push( `Line ${i}: receipt_index ${payload.receipt_index} !== expected ${i}` ); } // Step 6: Count solved receipts if (payload.is_solved === true) { nSolved++; } // Step 7: Compute hash of THIS line for next iteration prevHash = computeLineHash(line); } return { valid: errors.length === 0, n_receipts: lines.length, chain_root: chainRoot, chain_head: prevHash, n_solved: nSolved, score_01: lines.length > 0 ? nSolved / lines.length : 0, errors, }; } // ─── Chain Head Computation ─────────────────────────────────────────────────── /** * Computes only the chain head from a JSONL file without full validation. * * Faster than validateReceiptChain() when only the head hash is needed * (e.g., for publication to latest.json). * * Lean ref: SZL.AGI.PACBayes.capability_improvement_rate_bound * commit: c4d1379568 * * @param jsonlContent - Full content of a receipts.jsonl file * @returns ChainHeadResult with chain_head and n_lines */ export function computeChainHead(jsonlContent: string): ChainHeadResult { const lines = jsonlContent.split("\n").filter((l) => l.trim().length > 0); let head = CHAIN_GENESIS; for (const line of lines) { head = computeLineHash(line); } return { chain_head: head, n_lines: lines.length }; } // ─── Single Receipt Verification ───────────────────────────────────────────── /** * Verifies that a single receipt line correctly links to the expected previous hash. * * Used by auditors to spot-check specific receipts without replaying the full chain. * * @param line - Raw JSON line of the DSSE envelope (compact, UTF-8) * @param expectedPrevHash - Expected value of prev_hash (previous line's SHA-256 or "GENESIS") * @param expectedIndex - Expected receipt_index value * @returns Object with valid flag and errors array */ export function verifyReceiptLinkage( line: string, expectedPrevHash: string, expectedIndex: number ): { valid: boolean; errors: string[]; lineHash: string } { const errors: string[] = []; let payload: Record; try { const envelope = JSON.parse(line) as Record; const raw = Buffer.from(envelope.payload as string, "base64url").toString("utf8"); payload = JSON.parse(raw) as Record; } catch (e) { const lineHash = computeLineHash(line); return { valid: false, errors: [`Parse error: ${(e as Error).message}`], lineHash }; } if (payload.prev_hash !== expectedPrevHash) { errors.push( `prev_hash mismatch — expected "${expectedPrevHash}", got "${payload.prev_hash}"` ); } if (payload.receipt_index !== expectedIndex) { errors.push(`receipt_index ${payload.receipt_index} !== expected ${expectedIndex}`); } const lineHash = computeLineHash(line); return { valid: errors.length === 0, errors, lineHash }; } // ─── JSONL Line Builder ─────────────────────────────────────────────────────── /** * Builds a compact JSONL line from a DSSEEnvelope, suitable for appending to receipts.jsonl. * * IMPORTANT: Uses JSON.stringify without any whitespace to ensure hash consistency. * The SHA-256 of this string becomes the next receipt's prev_hash. * * @param envelope - Fully signed DSSEEnvelope * @returns Compact JSON string (no trailing newline) */ export function serializeEnvelope(envelope: DSSEEnvelope): string { return JSON.stringify(envelope); }