| 1 | // 🤖 The pure half of the append-only ledger streams (issue #1224): entry |
| 2 | // canonicalization, the hash chain, and link verification. No IO here — the |
| 3 | // append/verify tools and tests import this, so the chain rules are pinned |
| 4 | // in one Node-testable place (the format.js/ui.js convention). |
| 5 | // |
| 6 | // A stream is a sequence of immutable JSON objects in the status bucket: |
| 7 | // ledgers/<name>/entries/<ts-compact>-<sha12>.json |
| 8 | // Each entry carries the sha256 of the PREVIOUS entry's exact bytes — the |
| 9 | // fingerprinted stack. Tampering with any published entry breaks every hash |
| 10 | // after it: the chain makes edits EVIDENT (like the SSH ledger, this is |
| 11 | // transparency, not tamper-proofing — the bucket's versioning window is |
| 12 | // short, and a future anchor of the head hash into the public releases |
| 13 | // repo's hash ledger closes the loop). |
| 14 | import { createHash } from "node:crypto"; |
| 15 | |
| 16 | // Canonical bytes: stable key order, no whitespace — the sha is over these |
| 17 | // exact bytes, so two writers can't disagree about formatting. |
| 18 | export function canonicalBytes(obj) { |
| 19 | const sort = (v) => { |
| 20 | if (Array.isArray(v)) return v.map(sort); |
| 21 | if (v && typeof v === "object") { |
| 22 | return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])])); |
| 23 | } |
| 24 | return v; |
| 25 | }; |
| 26 | return Buffer.from(JSON.stringify(sort(obj)), "utf8"); |
| 27 | } |
| 28 | |
| 29 | export const sha256hex = (bytes) => createHash("sha256").update(bytes).digest("hex"); |
| 30 | |
| 31 | // Build the next entry in a stream. prev is null for the genesis entry, else |
| 32 | // { key, sha256 } of the current head. |
| 33 | export function buildEntry({ ledger, writer, ts, payload, prev }) { |
| 34 | if (!ledger || !writer || !ts) throw new Error("entry needs ledger, writer, ts"); |
| 35 | const entry = { |
| 36 | ledger, writer, ts, |
| 37 | payload, |
| 38 | prev_key: prev ? prev.key : null, |
| 39 | prev_sha256: prev ? prev.sha256 : null, |
| 40 | }; |
| 41 | const bytes = canonicalBytes(entry); |
| 42 | const sha = sha256hex(bytes); |
| 43 | // ts rides in the key so entries list/sort chronologically by name alone; |
| 44 | // the sha12 suffix makes the key content-derived and collision-safe. |
| 45 | const key = "ledgers/" + ledger + "/entries/" + |
| 46 | ts.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z") + "-" + sha.slice(0, 12) + ".json"; |
| 47 | return { entry, bytes, sha256: sha, key }; |
| 48 | } |
| 49 | |
| 50 | // Verify one link while walking the chain newest→oldest: the fetched bytes |
| 51 | // must hash to what the NEXT entry (or HEAD) claimed, and re-derive the key |
| 52 | // they were fetched under. |
| 53 | export function verifyLink({ bytes, key, claimedSha }) { |
| 54 | const sha = sha256hex(bytes); |
| 55 | if (sha !== claimedSha) { |
| 56 | return { ok: false, reason: "bytes hash " + sha.slice(0, 12) + "… but the chain claims " + claimedSha.slice(0, 12) + "…" }; |
| 57 | } |
| 58 | const entry = JSON.parse(bytes.toString("utf8")); |
| 59 | const expectKey = "ledgers/" + entry.ledger + "/entries/" + |
| 60 | entry.ts.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z") + "-" + sha.slice(0, 12) + ".json"; |
| 61 | if (key !== expectKey) { |
| 62 | return { ok: false, reason: "entry stored under " + key + " but its content derives " + expectKey }; |
| 63 | } |
| 64 | return { ok: true, entry }; |
| 65 | } |
| 66 | |