// 🤖 The pure half of the append-only ledger streams (issue #1224): entry // canonicalization, the hash chain, and link verification. No IO here — the // append/verify tools and tests import this, so the chain rules are pinned // in one Node-testable place (the format.js/ui.js convention). // // A stream is a sequence of immutable JSON objects in the status bucket: // ledgers//entries/-.json // Each entry carries the sha256 of the PREVIOUS entry's exact bytes — the // fingerprinted stack. Tampering with any published entry breaks every hash // after it: the chain makes edits EVIDENT (like the SSH ledger, this is // transparency, not tamper-proofing — the bucket's versioning window is // short, and a future anchor of the head hash into the public releases // repo's hash ledger closes the loop). import { createHash } from "node:crypto"; // Canonical bytes: stable key order, no whitespace — the sha is over these // exact bytes, so two writers can't disagree about formatting. export function canonicalBytes(obj) { const sort = (v) => { if (Array.isArray(v)) return v.map(sort); if (v && typeof v === "object") { return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])])); } return v; }; return Buffer.from(JSON.stringify(sort(obj)), "utf8"); } export const sha256hex = (bytes) => createHash("sha256").update(bytes).digest("hex"); // Build the next entry in a stream. prev is null for the genesis entry, else // { key, sha256 } of the current head. export function buildEntry({ ledger, writer, ts, payload, prev }) { if (!ledger || !writer || !ts) throw new Error("entry needs ledger, writer, ts"); const entry = { ledger, writer, ts, payload, prev_key: prev ? prev.key : null, prev_sha256: prev ? prev.sha256 : null, }; const bytes = canonicalBytes(entry); const sha = sha256hex(bytes); // ts rides in the key so entries list/sort chronologically by name alone; // the sha12 suffix makes the key content-derived and collision-safe. const key = "ledgers/" + ledger + "/entries/" + ts.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z") + "-" + sha.slice(0, 12) + ".json"; return { entry, bytes, sha256: sha, key }; } // Verify one link while walking the chain newest→oldest: the fetched bytes // must hash to what the NEXT entry (or HEAD) claimed, and re-derive the key // they were fetched under. export function verifyLink({ bytes, key, claimedSha }) { const sha = sha256hex(bytes); if (sha !== claimedSha) { return { ok: false, reason: "bytes hash " + sha.slice(0, 12) + "… but the chain claims " + claimedSha.slice(0, 12) + "…" }; } const entry = JSON.parse(bytes.toString("utf8")); const expectKey = "ledgers/" + entry.ledger + "/entries/" + entry.ts.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z") + "-" + sha.slice(0, 12) + ".json"; if (key !== expectKey) { return { ok: false, reason: "entry stored under " + key + " but its content derives " + expectKey }; } return { ok: true, entry }; }