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 | |
| 27 | import { createHash } from 'node:crypto'; |
| 28 | import { readdir, readFile, stat } from 'node:fs/promises'; |
| 29 | import { 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. |
| 37 | export 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. |
| 72 | export 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. |
| 85 | export const EXCLUDE_DIRS = ['vendor/', 'console/']; |
| 86 | |
| 87 | /// The genesis predecessor: a chain's first entry points at nothing. |
| 88 | export const GENESIS_PREV = '0'.repeat(64); |
| 89 | |
| 90 | /// SHA-256 of a buffer or string, lower-case hex. |
| 91 | export 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. |
| 97 | export 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`). |
| 106 | export 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. |
| 129 | export 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. |
| 141 | export 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. |
| 149 | export 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. |
| 156 | export 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. |
| 161 | export 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. |
| 174 | export 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. |
| 188 | export 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. |
| 206 | export 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 | } |