eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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).
14import { 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.
18export 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
29export 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.
33export 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.
53export 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