Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/verify/lib.mjs

10.1 KiB, 1 run

created by r2519314175:1025, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify/lib.mjs — the canonical shape of a Daimond bundle fingerprint.
2//
3// Daimond's whole privacy claim is checkable rather than promised: the client
4// is public, so anyone can read it, build it, and confirm the code their
5// browser is running IS that source. This module is the one definition of how a
6// build is fingerprinted, shared by the generator (writes the manifest), the
7// verifier (checks a served site against it) and the tests. The browser's own
8// `www/js/verify.js` recomputes the SAME fingerprint with Web Crypto, and a test
9// asserts the two agree — because a fingerprint two tools compute differently
10// verifies nothing.
11//
12// The algorithm, stated once so it can be reproduced anywhere:
13//
14// file hash = SHA-256(file bytes), lower-case hex.
15// manifest = each covered file as "<relpath>\n<filehash>\n", relpaths in
16// POSIX form, sorted byte-wise, concatenated.
17// bundle hash = SHA-256(manifest), lower-case hex — one figure for the whole
18// served surface, which changes if and only if any file does.
19//
20// The transparency log chains those bundle hashes so a served build must
21// correspond to a public, tamper-evident history — you cannot quietly serve one
22// user a different build without it showing up as an entry nobody else has.
23//
24// No dependencies: Node's own crypto and fs. Plain data in, plain data out, so
25// every function here is exercised without a browser or a network.
26
27import { createHash } from 'node:crypto';
28import { readdir, readFile, stat } from 'node:fs/promises';
29import { join, relative, sep } from 'node:path';
30
31/// The files under `www/` that are NOT part of the fingerprint.
32///
33/// `build.json` carries the staleness id and its own note, which move on their
34/// own schedule; `manifest.json` is the fingerprint itself and cannot contain
35/// its own hash. Everything else served — every script, style, page and the
36/// wasm that is the privacy-critical code — is covered.
37export const EXCLUDE = new Set([
38 'build.json', 'manifest.json',
39 // `releases.json` says which release a user is told they are running. That is
40 // editorial, not attestation: the chain attests what CODE shipped, and this
41 // only carries the name someone chose for it. It is excluded so a release can
42 // be declared on the server -- by an operator pulling the trigger -- without
43 // a redeploy, and so that renaming one does not false-fail an honest rebuild.
44 // The security claim is untouched: an attacker who could rewrite this could
45 // change a label and nothing else, and every byte the browser EXECUTES is
46 // still covered.
47 'releases.json',
48 // wasm-pack packaging metadata, not executed browser code: `pkg/LICENSE` is
49 // copied from the crate (so it differs between the proprietary dev build and
50 // the FSL public build), `pkg/package.json` carries the wasm-pack version (so
51 // it differs between toolchain versions), and `pkg/README.md` is a copy of the
52 // crate README (so it changes whenever the README is edited, coupling a prose
53 // change to the sealed bundle and false-failing an honest rebuild built after
54 // one). Covering any of them would false-fail an honest rebuild. What runs in
55 // the browser — the wasm and its .js glue — is covered; these are not.
56 'pkg/LICENSE', 'pkg/package.json', 'pkg/README.md',
57 // `pkg/source.json` is the provenance note `dev/build-wasm.sh` and `dev/gate.sh`
58 // leave beside the bundle: a SHA-256 per engine source file so `dev/staleguard.mjs`
59 // can tell a stale bundle from a bundle whose timestamps merely moved. It is not
60 // executed, and it is not attestation -- it says WHERE and WHEN a build happened,
61 // both of which differ for every honest rebuild, so sealing it would make the
62 // published claim ("clone it, build it, compare the hash") false for everybody
63 // including the author's next build. It is excluded for the same reason
64 // `pkg/package.json` is, one step further: not merely toolchain-dependent but
65 // build-INSTANCE-dependent. The two halves are one change -- taught to write the
66 // note, taught not to seal it -- and `dev/repro-check.sh` is what proves it.
67 'pkg/source.json',
68]);
69
70/// File suffixes left out of the fingerprint: TypeScript type stubs, which the
71/// browser never executes and which exist only for editor tooling.
72export const EXCLUDE_SUFFIXES = ['.d.ts'];
73
74/// Directory prefixes left out of the fingerprint.
75///
76/// `vendor/` holds third-party downloadable tooling (the Typst compiler wasm and
77/// its fonts) — not built from Daimond's source, and gitignored, so a fresh
78/// clone could never reproduce it. It carries its own integrity story as a
79/// signed tool-library download. `console/` is the operator console, which is
80/// NOT part of the public client (it is pruned from the open-source repo) and
81/// which a normal user's browser never loads. What this manifest covers is the
82/// user-facing client — every byte of which is public and rebuilds byte-for-byte
83/// from that source; so a reader can confirm the code THEIR browser runs is the
84/// published source, which is the whole claim.
85export const EXCLUDE_DIRS = ['vendor/', 'console/'];
86
87/// The genesis predecessor: a chain's first entry points at nothing.
88export const GENESIS_PREV = '0'.repeat(64);
89
90/// SHA-256 of a buffer or string, lower-case hex.
91export function sha256(data) {
92 return createHash('sha256').update(data).digest('hex');
93}
94
95/// A relative path in POSIX form, so a manifest built on Windows and one built
96/// on Linux fingerprint identically.
97export function posix(rel) {
98 return sep === '/' ? rel : rel.split(sep).join('/');
99}
100
101/// Every covered file under `root`, as POSIX relpaths, sorted byte-wise.
102///
103/// The defaults are the served bundle's rules, which is what almost every caller wants. `opts`
104/// overrides them for a tree that is not `www/` — the hand's published source is hashed the same
105/// way and leaves out a different set of things (`verify/hand.mjs`).
106export async function coveredFiles(root, opts = {}) {
107 const exclude = opts.exclude || EXCLUDE;
108 const excludeDirs = opts.excludeDirs || EXCLUDE_DIRS;
109 const excludeSuffixes = opts.excludeSuffixes || EXCLUDE_SUFFIXES;
110 const out = [];
111 async function walk(dir) {
112 const ents = await readdir(dir, { withFileTypes: true });
113 for (const ent of ents) {
114 const p = join(dir, ent.name);
115 const rel = posix(relative(root, p));
116 if (excludeDirs.some(d => (rel + '/').startsWith(d))) continue;
117 if (ent.isDirectory()) { await walk(p); continue; }
118 if (exclude.has(rel)) continue;
119 if (excludeSuffixes.some(s => rel.endsWith(s))) continue;
120 out.push(rel);
121 }
122 }
123 await walk(root);
124 out.sort();
125 return out;
126}
127
128/// The `{ relpath: filehash }` map for a directory tree.
129export async function hashTree(root, opts = {}) {
130 const files = await coveredFiles(root, opts);
131 const map = {};
132 for (const rel of files) {
133 map[rel] = sha256(await readFile(join(root, rel)));
134 }
135 return map;
136}
137
138/// The canonical manifest text for a `{ relpath: filehash }` map: sorted
139/// "<relpath>\n<filehash>\n" lines. This is the exact preimage the bundle hash
140/// is taken over, and the browser builds the identical string.
141export function manifestText(files) {
142 const rels = Object.keys(files).sort();
143 let s = '';
144 for (const rel of rels) s += rel + '\n' + files[rel] + '\n';
145 return s;
146}
147
148/// The one-figure fingerprint of a whole bundle, from its file map.
149export function bundleHash(files) {
150 return sha256(manifestText(files));
151}
152
153/// The chained hash of one transparency entry. Any change to a past entry —
154/// its build, its bundle, its order — moves this, and therefore every entry
155/// after it, so a rewritten history cannot stay self-consistent.
156export function entryHash({ seq, ts, build, bundle, prev }) {
157 return sha256(`${seq}|${ts}|${build}|${bundle}|${prev}`);
158}
159
160/// Parse a transparency log (JSON-lines text) into entries, skipping blanks.
161export function parseLog(text) {
162 return text.split('\n')
163 .map(l => l.trim())
164 .filter(Boolean)
165 .map(l => JSON.parse(l));
166}
167
168/// Check a transparency log is a well-formed, unbroken chain from genesis.
169///
170/// Returns `{ ok, error, seq }`. A log verifies when every entry's `prev` is
171/// the entry before it (genesis for the first), its `seq` is its position, and
172/// its recomputed `entry` hash matches what is stored. A single altered byte in
173/// any past entry fails one of these.
174export function verifyChain(entries) {
175 let prev = GENESIS_PREV;
176 for (let i = 0; i < entries.length; i++) {
177 const e = entries[i];
178 if (e.seq !== i) return { ok: false, error: `entry ${i} has seq ${e.seq}`, seq: i };
179 if (e.prev !== prev) return { ok: false, error: `entry ${i} does not chain onto ${i - 1}`, seq: i };
180 const want = entryHash(e);
181 if (e.entry !== want) return { ok: false, error: `entry ${i} hash does not match its contents`, seq: i };
182 prev = e.entry;
183 }
184 return { ok: true, error: '', seq: entries.length };
185}
186
187/// The next entry to append to a chain, given the current entries.
188export function nextEntry(entries, { ts, build, bundle, note }) {
189 const seq = entries.length;
190 const prev = entries.length ? entries[entries.length - 1].entry : GENESIS_PREV;
191 const base = { seq, ts, build, bundle, prev };
192 // The note rides OUTSIDE the hashed preimage, deliberately. `entryHash`
193 // covers seq|ts|build|bundle|prev and nothing else, so a note can be added
194 // to an entry -- including one sealed long ago -- without moving its hash,
195 // and every entry after it stays valid. What the chain attests is what was
196 // shipped; the note is only what a human called it, and it must never be
197 // able to invalidate the attestation.
198 const out = { ...base, entry: entryHash(base) };
199 if (note) out.note = String(note).slice(0, 200);
200 return out;
201}
202
203/// Compare an expected file map against an actual one, returning the
204/// mismatches: files missing from what was served, files served that the
205/// manifest does not list, and files whose hash differs.
206export function diffFiles(expected, actual) {
207 const out = { missing: [], unexpected: [], changed: [] };
208 for (const rel of Object.keys(expected)) {
209 if (!(rel in actual)) out.missing.push(rel);
210 else if (actual[rel] !== expected[rel]) out.changed.push(rel);
211 }
212 for (const rel of Object.keys(actual)) {
213 if (!(rel in expected)) out.unexpected.push(rel);
214 }
215 return out;
216}