Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/verify.js

6.9 KiB, 1 run

created by r2519314175:1471, 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/* ============================================================
2 Daimond — in-page delivery check (DaimondVerify)
3 ------------------------------------------------------------
4 "The code your browser is running is the published source."
5 This lets a user check that from inside the running app.
6
7 Be honest about what a page can and cannot prove about itself:
8 a tampered server could serve a tampered checker, so a page
9 verifying itself is a convenience, not a proof. Its ONE
10 genuinely load-bearing check is against something this server
11 does NOT control — the transparency log published in the
12 public repo, fetched from a different origin. If the bundle
13 this server served is not a sealed entry in that public,
14 hash-chained history, something is wrong however green the
15 rest looks.
16
17 The trustworthy verdict is `verify/check.mjs`, run from the
18 source you cloned, or the browser extension — neither of which
19 this server can touch. This module says so, every time.
20
21 Exposes `window.DaimondVerify`. Depends on nothing.
22 ============================================================ */
23(function () {
24 'use strict';
25
26 /// What the app says. The check names and their details are read by a
27 /// person deciding whether to trust the page they are on.
28 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
29
30 // The transparency log in the PUBLIC repo, on an origin this server does not
31 // control. Overridable with <meta name="daimond-log" content="..."> so a fork
32 // or a mirror can point at its own. The default is the canonical repo.
33 var META = document.querySelector('meta[name="daimond-log"]');
34 var LOG_URL = (META && META.content)
35 || 'https://raw.githubusercontent.com/oxedyne-com/daimond/main/verify/transparency.jsonl';
36 var GENESIS_PREV = '0'.repeat(64);
37
38 // ── The canonical fingerprint, byte-identical to verify/lib.mjs ─────
39 function hex(buf) {
40 var v = new Uint8Array(buf), s = '';
41 for (var i = 0; i < v.length; i++) s += v[i].toString(16).padStart(2, '0');
42 return s;
43 }
44 async function sha256(bytes) {
45 return hex(await crypto.subtle.digest('SHA-256', bytes));
46 }
47 async function sha256str(str) {
48 return sha256(new TextEncoder().encode(str));
49 }
50 function manifestText(files) {
51 var rels = Object.keys(files).sort(), s = '';
52 for (var i = 0; i < rels.length; i++) s += rels[i] + '\n' + files[rels[i]] + '\n';
53 return s;
54 }
55 async function bundleHash(files) {
56 return sha256str(manifestText(files));
57 }
58 async function entryHash(e) {
59 return sha256str(e.seq + '|' + e.ts + '|' + e.build + '|' + e.bundle + '|' + e.prev);
60 }
61 async function verifyChain(entries) {
62 var prev = GENESIS_PREV;
63 for (var i = 0; i < entries.length; i++) {
64 var e = entries[i];
65 if (e.seq !== i) return { ok: false, error: t('verify.chain_order', { n: i }) };
66 if (e.prev !== prev) return { ok: false, error: t('verify.chain_break', { n: i, prev: i - 1 }) };
67 if (e.entry !== await entryHash(e)) return { ok: false, error: t('verify.chain_hash', { n: i }) };
68 prev = e.entry;
69 }
70 return { ok: true, error: '' };
71 }
72
73 // ── The check ───────────────────────────────────────────────────────
74
75 /// Verify this page against its manifest and the public transparency log.
76 ///
77 /// `opts.files` (default true) also fetches and re-hashes every covered file;
78 /// set false for the quick check (manifest self-consistency + chain
79 /// membership only), which does not pull the whole bundle down again.
80 /// `opts.onProgress(done, total)` is called as files are hashed.
81 ///
82 /// Returns a verdict:
83 /// { ok, build, bundle, checks: [{ name, ok, detail }], caveat }
84 /// where `ok` is true only if every check that could run passed.
85 async function check(opts) {
86 opts = opts || {};
87 var doFiles = opts.files !== false;
88 var checks = [];
89 var add = function (name, ok, detail) { checks.push({ name: name, ok: ok, detail: detail || '' }); };
90
91 var manifest;
92 try {
93 manifest = await (await fetch('manifest.json', { cache: 'no-store' })).json();
94 } catch (e) {
95 add(t('verify.check_manifest'), false, t('verify.no_manifest'));
96 return verdict(checks, null);
97 }
98
99 // 1. The manifest is internally consistent: its bundle hash is the hash of
100 // its own file list. (Weak on its own — the server wrote both — but a
101 // fast, necessary sanity gate.)
102 var recomputed = await bundleHash(manifest.files);
103 add(t('verify.check_self'), recomputed === manifest.bundle,
104 recomputed === manifest.bundle ? '' : t('verify.self_mismatch'));
105
106 // 2. THE one that matters: the served bundle is a sealed entry in the
107 // public, hash-chained log on an origin this server does not control.
108 try {
109 var text = await (await fetch(LOG_URL, { cache: 'no-store' })).text();
110 var entries = text.split('\n').map(function (l) { return l.trim(); }).filter(Boolean).map(JSON.parse);
111 var chain = await verifyChain(entries);
112 if (!chain.ok) {
113 add(t('verify.check_log'), false, t('verify.log_broken', { reason: chain.error }));
114 } else {
115 var sealed = entries.some(function (e) { return e.bundle === manifest.bundle; });
116 add(t('verify.check_sealed'), sealed,
117 sealed ? t('verify.on_record', { n: entries.length })
118 : t('verify.never_published'));
119 }
120 } catch (e) {
121 add(t('verify.check_log'), null, t('verify.log_unreachable'));
122 }
123
124 // 3. Optionally, every served file hashes to what the manifest says. This
125 // catches partial tampering and CDN drift, though a server that rewrote
126 // the files could rewrite the manifest to match — which is why (2) is
127 // the check that counts.
128 if (doFiles) {
129 var rels = Object.keys(manifest.files), bad = [], done = 0;
130 for (var i = 0; i < rels.length; i++) {
131 var rel = rels[i];
132 try {
133 var res = await fetch(rel, { cache: 'no-store' });
134 var got = await sha256(new Uint8Array(await res.arrayBuffer()));
135 if (got !== manifest.files[rel]) bad.push(rel);
136 } catch (e) { bad.push(rel + ' ' + t('verify.unreadable')); }
137 done++;
138 if (opts.onProgress) try { opts.onProgress(done, rels.length); } catch (e) {}
139 }
140 add(t('verify.check_files'), bad.length === 0,
141 bad.length ? t('verify.files_differ', { n: bad.length, list: bad.slice(0, 8).join(', ') })
142 : t('verify.files_ok', { n: rels.length }));
143 }
144
145 return verdict(checks, manifest);
146 }
147
148 function verdict(checks, manifest) {
149 // A null result (could-not-check) does not fail the verdict, but it does
150 // stop it being a clean pass: the answer is "unproven", not "fine".
151 var anyFail = checks.some(function (c) { return c.ok === false; });
152 var anyUnknown = checks.some(function (c) { return c.ok === null; });
153 return {
154 ok: !anyFail && !anyUnknown,
155 failed: anyFail,
156 build: manifest ? manifest.build : '',
157 bundle: manifest ? manifest.bundle : '',
158 checks: checks,
159 caveat: t('verify.caveat'),
160 };
161 }
162
163 window.DaimondVerify = { check: check, LOG_URL: LOG_URL };
164})();