Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_qrscan.mjs

8.2 KiB, 1 run

created by r2519314175:615, 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_qrscan.mjs — the QR reader is held to the QR writer, at every version.
2//
3// THE WRITER IS THE ORACLE. `fe2o3_graphics::qr` is a from-scratch ISO 18004
4// encoder in Rust; `www/js/qrscan.js` is the reading half in JavaScript, for the
5// browsers with no `BarcodeDetector`. Two implementations of one format is
6// exactly the arrangement that drifts, so the reader is never asked to agree
7// with itself: it is asked to read what the Rust wrote, at every version the
8// Rust will produce, with no picture in between and then with one.
9//
10// The version range is the point. The codec this reader is ported from
11// (oxegen/www/public/js/qr.js) stops at version 10, and a signed identity card
12// is 336 bytes, whose `#c=` URL needs version 17. A reader capped at 10 could
13// never once have read the thing Daimond shows it.
14//
15// 1. Matrix in, matrix out: every version the encoder reaches round-trips.
16// 2. A rendered picture — 6px modules, 4-module quiet zone, the pairing
17// canvas's own geometry — decodes to the same text.
18// 3. A REAL signed card URL goes through the whole path and parses back to
19// the same key.
20//
21// Needs the dev server only (DAIMOND_PORT). No gateway.
22import { open, shot, errors } from './harness.mjs';
23
24const ok = [], bad = [];
25const check = (name, pass, detail) => {
26 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
27 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
28};
29
30const s = await open({ name: 'qrscan', signIn: true, connect: false });
31const { page } = s;
32await page.waitForFunction(() => !!window.DaimondQR && !!window.DaimondCrypto, null, { timeout: 20000 });
33
34// trust.js landed in index.html at 5c14eea, and qrscan.js is loaded on demand by
35// trust.js rather than tagged. The check below is the reachability gate and is
36// meant to be read: it is now a regression guard rather than a pending item.
37const tagged = await page.evaluate(async () => {
38 const html = await (await fetch('/index.html')).text();
39 // A SCRIPT TAG, not the string. index.html carries a comment naming
40 // js/trust.js, and matching that reported the tag present for a build that
41 // never loaded the file — a verifier passing for the wrong reason.
42 const tag = n => new RegExp('<script[^>]+src=["\']js/' + n + '\\.js["\']').test(html);
43 return { trust: tag('trust'), scan: tag('qrscan') };
44});
45check('index.html loads js/trust.js', tagged.trust,
46 tagged.trust ? '' : 'GONE — the People button and the #c= handler cannot run without it');
47console.log(' note js/qrscan.js is loaded on demand by trust.js, so it wants no tag of its own'
48 + (tagged.scan ? ' (one is present)' : ''));
49
50await page.addScriptTag({ url: 'js/trust.js' });
51await page.evaluate(() => window.DaimondTrust.scanner());
52await page.waitForFunction(() => !!window.DaimondQRScan, null, { timeout: 10000 });
53
54try {
55 // ── 1. Matrix in, matrix out, at every version the encoder reaches. ───────
56 const walk = await page.evaluate(() => {
57 const seen = {};
58 const fails = [];
59 // Lengths chosen to walk the whole version range: the encoder picks the
60 // smallest version that fits at Medium, so growing the payload steps it up.
61 for (let len = 8; len <= 2200; len += 7) {
62 let text = '';
63 for (let i = 0; i < len; i++) text += String.fromCharCode(48 + (i % 74));
64 const grid = window.DaimondQR.matrix(text);
65 if (!grid || !grid.length) continue;
66 const side = Math.round(Math.sqrt(grid.length));
67 const version = (side - 17) / 4;
68 if (seen[version]) continue;
69 const got = window.DaimondQRScan.fromMatrix(grid, side);
70 seen[version] = got && got.text === text ? 'ok' : 'FAIL';
71 if (seen[version] !== 'ok') {
72 fails.push({ version, side, len, got: got ? got.text.length : null });
73 }
74 }
75 const versions = Object.keys(seen).map(Number).sort((a, b) => a - b);
76 const read = versions.filter(v => seen[v] === 'ok');
77 return { versions, read, fails, highest: read[read.length - 1] };
78 });
79 check('every version the encoder produced was read back exactly',
80 walk.fails.length === 0,
81 `${walk.versions.length} versions, ${walk.versions[0]}–${walk.highest}`
82 + (walk.fails.length ? `; failed at ${walk.fails.map(f => 'v' + f.version).join(', ')}` : ''));
83 // The version an identity card needs. Named on its own, because this is the
84 // one the ported ceiling of 10 would have missed — and it asks whether v17
85 // was READ, not merely whether the encoder reached it. The weaker form of
86 // this check passed on a build that could not read a single symbol above v10.
87 check('version 17 was read back (a signed card needs it)', walk.read.indexOf(17) >= 0,
88 `read ${walk.read.length} of ${walk.versions.length} versions`);
89 check('the versions read reach past 30', walk.highest >= 30, `highest read v${walk.highest}`);
90
91 // ── 2. A rendered picture, at the geometry the app actually draws. ────────
92 const pic = await page.evaluate(() => {
93 const out = [];
94 const texts = [
95 'DMND-ID1.short',
96 'https://daimond.oxedyne.com/#c=' + 'A'.repeat(300),
97 'https://daimond.oxedyne.com/#c=' + 'B'.repeat(700),
98 ];
99 for (const text of texts) {
100 const grid = window.DaimondQR.matrix(text);
101 const n = Math.round(Math.sqrt(grid.length));
102 const quiet = 4, scale = 6, dim = (n + quiet * 2) * scale;
103 const c = document.createElement('canvas');
104 c.width = dim; c.height = dim;
105 const ctx = c.getContext('2d', { willReadFrequently: true });
106 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, dim, dim);
107 ctx.fillStyle = '#000000';
108 for (let y = 0; y < n; y++) {
109 for (let x = 0; x < n; x++) {
110 if (grid[y * n + x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
111 }
112 }
113 const frame = ctx.getImageData(0, 0, dim, dim);
114 const got = window.DaimondQRScan.decode(frame);
115 out.push({ version: (n - 17) / 4, px: dim, read: !!got && got.text === text });
116 }
117 return out;
118 });
119 for (const p of pic) {
120 check(`a drawn symbol at v${p.version} decoded from its pixels`, p.read, `${p.px}px canvas`);
121 }
122
123 // ── 3. The real thing: a signed card, all the way round. ──────────────────
124 const real = await page.evaluate(async () => {
125 await window.DaimondIdentity.ensureSealingKey();
126 await window.DaimondIdentity.mintCard();
127 const url = window.DaimondTrust.cardUrl();
128 const grid = window.DaimondQR.matrix(url);
129 if (!grid || !grid.length) return { encoded: false };
130 const n = Math.round(Math.sqrt(grid.length));
131 const quiet = 4, scale = 6, dim = (n + quiet * 2) * scale;
132 const c = document.createElement('canvas');
133 c.width = dim; c.height = dim;
134 const ctx = c.getContext('2d', { willReadFrequently: true });
135 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, dim, dim);
136 ctx.fillStyle = '#000000';
137 for (let y = 0; y < n; y++) {
138 for (let x = 0; x < n; x++) {
139 if (grid[y * n + x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
140 }
141 }
142 const got = window.DaimondQRScan.decode(ctx.getImageData(0, 0, dim, dim));
143 const card = got ? window.DaimondTrust.parse(got.text) : null;
144 const mine = window.DaimondIdentity.publicKeyB64url();
145 const mineHex = Array.from(atob(mine.replace(/-/g, '+').replace(/_/g, '/')))
146 .map(ch => (ch.charCodeAt(0) + 256).toString(16).slice(1)).join('');
147 return {
148 encoded: true, version: (n - 17) / 4, urlLen: url.length,
149 read: !!got, sameText: !!got && got.text === url,
150 parsed: !!card, sameKey: !!card && card.key === mineHex,
151 label: card ? card.label : '',
152 };
153 });
154 check('a signed identity card fits a QR at all', real.encoded, `v${real.version}, ${real.urlLen} chars`);
155 check('the card symbol decoded from its own pixels', real.sameText);
156 // The check with teeth: not "something came back" but "the bytes verified as
157 // a card and named the same 32-byte key". A reader that returned a truncated
158 // string would pass a length check and fail this one.
159 check('what came back verified as a card naming the same key', real.sameKey,
160 real.parsed ? `label "${real.label}"` : 'did not parse');
161
162 await shot(s, 'qrscan');
163} finally {
164 const errs = errors(s);
165 if (errs.length) console.log(' console errors:', errs.slice(0, 5));
166 await s.close();
167}
168
169console.log(`\n${ok.length} ok, ${bad.length} failed`);
170if (bad.length) { bad.forEach(b => console.log(' FAIL ' + b)); process.exit(1); }