Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_trust.mjs

25.8 KiB, 1 run

created by r2519314175:763, 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_trust.mjs — first contact, with no server in the path at all.
2//
3// This is the phase's own proof and it is shaped like the claim: two devices,
4// two identities, no gateway. The gateway is not merely unused, it is NOT
5// RUNNING; the cards cross between the two browsers through this script, the
6// way a camera carries them across a table, and each side computes the safety
7// number for itself and the two are compared here rather than by either of them.
8//
9// What each block settles:
10//
11// A Two devices agree on a sixty-digit number that neither of them sent
12// anywhere. The number is a function of BOTH keys, and no request either
13// browser made carried either key or the number.
14// B The three transports carry one artefact, and a card with a byte changed
15// does not parse.
16// C The TrustEdge is signed, the log is hash chained, and the replay CHECKS
17// the signature — an edge with its signature scribbled out projects the
18// person back to "new" rather than keeping a state it cannot justify.
19// D Decision 4: a card that arrived asynchronously is never offered
20// "Mark matched now". A card read by this device's own camera is.
21// E A look-alike label WARNS beside a key and never blocks it.
22// F Rotation is a claim and not a transfer: a new key naming a matched one
23// as its predecessor is "changed", loudly, and messages are held.
24// G The two-axis wording: never "verified", never "trusted", and drawn as a
25// LINE UNDER THE NAME rather than a badge beside it — checked by geometry,
26// which no translation can defeat, and by reading all eight locale files.
27// H Reachability: the panel seam is fed, and index.html loads the file.
28//
29// Needs the dev server only (DAIMOND_PORT). Deliberately no gateway.
30import fs from 'node:fs';
31import path from 'node:path';
32import { fileURLToPath } from 'node:url';
33import { open, shot, errors } from './harness.mjs';
34import { GW_PORT, GW_URL } from './ports.mjs';
35
36const HERE = path.dirname(fileURLToPath(import.meta.url));
37const WWW = path.join(HERE, '..', 'www');
38
39const ok = [], bad = [];
40const check = (name, pass, detail) => {
41 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
42 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
43};
44
45/// Bring a session up with trust.js loaded, an unlocked identity and a card.
46async function device(name) {
47 const s = await open({ name, signIn: true, connect: false });
48 s.seen = [];
49 s.page.on('request', r => {
50 let body = '';
51 try { body = r.postData() || ''; } catch (e) { /* not a body we can read */ }
52 s.seen.push(r.url() + ' ' + body);
53 });
54 await s.page.waitForFunction(() => !!window.DaimondCrypto, null, { timeout: 20000 });
55 await s.page.addScriptTag({ url: 'js/trust.js' });
56 await s.page.waitForFunction(() => !!window.DaimondTrust, null, { timeout: 10000 });
57 await s.page.evaluate(async () => {
58 await window.DaimondIdentity.ensureSealingKey();
59 await window.DaimondIdentity.mintCard();
60 });
61 s.key = await s.page.evaluate(() => {
62 const b64 = window.DaimondIdentity.publicKeyB64url().replace(/-/g, '+').replace(/_/g, '/');
63 return Array.from(atob(b64)).map(c => (c.charCodeAt(0) + 256).toString(16).slice(1)).join('');
64 });
65 return s;
66}
67
68/// Was there a gateway to lean on at all? The answer wanted is "no".
69let gatewayUp = false;
70try {
71 const r = await fetch(`${GW_URL}/`, { signal: AbortSignal.timeout(800) });
72 gatewayUp = !!r;
73} catch (e) { gatewayUp = false; }
74
75const A = await device('trustb-a');
76const B = await device('trustb'); // the label a look-alike will imitate
77// A stranger, never matched, so the offers a card gets can be read without a
78// prior match already having answered the question.
79const S = await device('trust8'); // "trust8" folds onto "trustb": 8 → b
80
81try {
82 console.log(`\n── A. Two devices, no server ${'─'.repeat(40)}`);
83 check('the gateway was not running for any of this', !gatewayUp,
84 gatewayUp ? `something answered on :${GW_PORT} — the wire checks below still hold`
85 : `nothing on :${GW_PORT}`);
86
87 // The cards cross through this script and through nothing else. Neither
88 // browser was asked to fetch anything to obtain the other's key.
89 const aCard = await A.page.evaluate(() => window.DaimondTrust.cardText());
90 const bCard = await B.page.evaluate(() => window.DaimondTrust.cardText());
91 const aUrl = await A.page.evaluate(() => window.DaimondTrust.cardUrl());
92
93 const aSees = await A.page.evaluate(async (text) => {
94 const card = window.DaimondTrust.parse(text);
95 if (!card) return null;
96 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.QR);
97 return { key: card.key, fp: card.fp, label: card.label };
98 }, bCard);
99 const bSees = await B.page.evaluate(async (text) => {
100 const card = window.DaimondTrust.parse(text);
101 if (!card) return null;
102 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.QR);
103 return { key: card.key, fp: card.fp, label: card.label };
104 }, aCard);
105 check('each device read the other\'s card', !!aSees && !!bSees,
106 aSees && bSees ? `${aSees.fp} / ${bSees.fp}` : 'a card did not verify');
107 check('the key each read is the key the other holds',
108 aSees && bSees && aSees.key === B.key && bSees.key === A.key);
109
110 // Computed independently, on each device, and compared HERE.
111 const aNum = await A.page.evaluate(k => window.DaimondTrust.safetyNumber(k), B.key);
112 const bNum = await B.page.evaluate(k => window.DaimondTrust.safetyNumber(k), A.key);
113 check('both devices computed the same safety number', !!aNum && aNum === bNum,
114 aNum ? aNum.slice(0, 23) + '…' : 'none');
115 const groups = String(aNum).split(/\s+/).filter(Boolean);
116 check('sixty digits in twelve groups of five',
117 groups.length === 12 && groups.every(g => /^[0-9]{5}$/.test(g)),
118 `${groups.length} groups, ${groups.join('').length} digits`);
119 // A number that ignored one of the two keys would pass every check above.
120 const otherNum = await A.page.evaluate(() =>
121 window.DaimondTrust.safetyNumber('11'.repeat(32)));
122 check('the number is a function of BOTH keys', !!otherNum && otherNum !== aNum);
123 // And symmetric under the order of the pair, which is what lets two people
124 // compare without first agreeing who is first.
125 const symmetric = await A.page.evaluate(async (bk) => {
126 const mine = await window.DaimondIdentity.publicKeyRaw();
127 const theirs = Uint8Array.from(bk.match(/../g).map(h => parseInt(h, 16)));
128 return window.DaimondCrypto.safetyNumber(mine, theirs)
129 === window.DaimondCrypto.safetyNumber(theirs, mine);
130 }, B.key);
131 check('and symmetric whichever key is given first', symmetric);
132
133 // THE WIRE. Nothing either browser asked for carried the other's key or the
134 // number they agreed on.
135 const wire = [...A.seen, ...B.seen].join('\n');
136 const leaked = [A.key, B.key, aNum, aNum.replace(/\s+/g, '')]
137 .filter(v => v && wire.indexOf(v) >= 0);
138 check('no request from either browser carried a key or the number', leaked.length === 0,
139 `${A.seen.length + B.seen.length} requests seen`);
140
141 console.log(`\n── B. The transports ${'─'.repeat(48)}`);
142 check('the paste form is DMND-ID1.', aCard.slice(0, 9) === 'DMND-ID1.', aCard.slice(0, 20) + '…');
143 check('the URL form is a fragment, so it reaches no server', /#c=/.test(aUrl) && !/\?c=/.test(aUrl));
144 const viaUrl = await B.page.evaluate(u => {
145 const c = window.DaimondTrust.parse(u);
146 return c ? c.key : '';
147 }, aUrl);
148 check('the URL form parses to the same key as the paste form', viaUrl === A.key);
149 // One byte changed anywhere in the artefact and it is not a card.
150 const damaged = await B.page.evaluate(t => {
151 const b64 = t.slice(9).replace(/-/g, '+').replace(/_/g, '/');
152 const raw = atob(b64 + '='.repeat((4 - b64.length % 4) % 4));
153 const bytes = Array.from(raw).map(c => c.charCodeAt(0));
154 const hits = [];
155 for (const at of [20, Math.floor(bytes.length / 2), bytes.length - 3]) {
156 const copy = bytes.slice();
157 copy[at] ^= 0x01;
158 const s = btoa(String.fromCharCode.apply(null, copy))
159 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
160 hits.push(!!window.DaimondTrust.parse('DMND-ID1.' + s));
161 }
162 return hits;
163 }, aCard);
164 check('a card with one byte changed does not parse', damaged.every(h => h === false),
165 `parsed ${damaged.filter(Boolean).length} of ${damaged.length} damaged copies`);
166
167 // SHOWING is a caller, not a build: the one QR drawer in the app is
168 // pairing.js's, and this proves the card reaches it and comes out as ink.
169 const shown = await A.page.evaluate(async () => {
170 document.querySelectorAll('.pair-scrim').forEach(n => n.remove());
171 window.DaimondTrust.showCard();
172 await new Promise(r => setTimeout(r, 600));
173 const c = document.querySelector('.pair-scrim canvas.pair-qr');
174 if (!c) return { drawn: false };
175 const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
176 let black = 0, white = 0;
177 for (let i = 0; i < d.length; i += 4) {
178 if (d[i] < 40 && d[i + 1] < 40 && d[i + 2] < 40) black++;
179 else if (d[i] > 215 && d[i + 1] > 215 && d[i + 2] > 215) white++;
180 }
181 const paste = (document.querySelector('.pair-scrim textarea') || {}).value || '';
182 document.querySelectorAll('.pair-scrim').forEach(n => n.remove());
183 return { drawn: true, px: c.width, black, white, paste };
184 });
185 check('showing my code draws a real symbol through the app\'s one QR drawer',
186 shown.drawn && shown.black > 100 && shown.white > 100,
187 shown.drawn ? `${shown.px}px, ${shown.black} dark / ${shown.white} light` : 'no canvas');
188 check('and the same screen offers the paste form beside it',
189 String(shown.paste).slice(0, 9) === 'DMND-ID1.');
190
191 console.log(`\n── C. The edge, the log, and what the replay checks ${'─'.repeat(18)}`);
192 await A.page.evaluate(k => window.DaimondTrust.markMatched(k, window.DaimondTrust.METHOD.QR), B.key);
193 await B.page.evaluate(k => window.DaimondTrust.markMatched(k, window.DaimondTrust.METHOD.QR), A.key);
194 const edge = await A.page.evaluate(() => {
195 const log = window.DaimondTrust.log();
196 const e = log.filter(x => x.k === 'edge').pop();
197 return e || null;
198 });
199 check('an edge was written', !!edge, edge ? `${edge.method}, scope ${edge.scope}` : 'none');
200 check('its scope is IDENTITY and there is no other arm', edge && edge.scope === 'identity');
201 check('its method names an act on a KEY, not a claim about a person',
202 edge && ['in_person_qr', 'safety_number'].indexOf(edge.method) >= 0, edge && edge.method);
203 check('it carries a nonce and a signature', edge && !!edge.nonce && !!edge.sig);
204 const chainOk = await A.page.evaluate(() => window.DaimondTrust.chainBreak());
205 check('the log\'s hash chain is intact', chainOk === -1, `break at ${chainOk}`);
206
207 const matched = await A.page.evaluate(async k => {
208 const p = await window.DaimondTrust.person(k);
209 return p ? { state: p.state, method: p.method, words: window.DaimondTrust.keyWords(p) } : null;
210 }, B.key);
211 check('the projection reads that key as matched', matched && matched.state === 'matched',
212 matched ? matched.words : 'no person');
213
214 // THE CHECK WITH TEETH. Scribble out the signature and replay: an assertion
215 // that merely read `edge.method` would still say "matched".
216 const tampered = await A.page.evaluate(async k => {
217 const log = JSON.parse(localStorage.getItem('daimond-trust-log'));
218 const keep = JSON.stringify(log);
219 for (const e of log) if (e.k === 'edge') e.sig = e.sig.slice(0, -4) + 'AAAA';
220 localStorage.setItem('daimond-trust-log', JSON.stringify(log));
221 window.DaimondTrust.forget();
222 const p = await window.DaimondTrust.person(k);
223 const state = p ? p.state : 'gone';
224 localStorage.setItem('daimond-trust-log', keep);
225 window.DaimondTrust.forget();
226 const back = await window.DaimondTrust.person(k);
227 return { state, restored: back ? back.state : 'gone' };
228 }, B.key);
229 check('an edge whose signature was changed no longer matches anybody',
230 tampered.state === 'new', `state became "${tampered.state}"`);
231 check('and the untouched log still reads matched', tampered.restored === 'matched');
232
233 // The same question of the card half.
234 const cardTampered = await A.page.evaluate(async k => {
235 const log = JSON.parse(localStorage.getItem('daimond-trust-log'));
236 const keep = JSON.stringify(log);
237 for (const e of log) {
238 if (e.k !== 'card') continue;
239 const raw = atob(e.a);
240 const bytes = Array.from(raw).map(c => c.charCodeAt(0));
241 bytes[Math.floor(bytes.length / 2)] ^= 0x01;
242 e.a = btoa(String.fromCharCode.apply(null, bytes));
243 }
244 localStorage.setItem('daimond-trust-log', JSON.stringify(log));
245 window.DaimondTrust.forget();
246 const p = await window.DaimondTrust.person(k);
247 localStorage.setItem('daimond-trust-log', keep);
248 window.DaimondTrust.forget();
249 return p ? p.state : 'gone';
250 }, B.key);
251 check('a card whose bytes were changed is no claim at all', cardTampered === 'gone',
252 `projected as "${cardTampered}"`);
253
254 const broke = await A.page.evaluate(async () => {
255 const log = JSON.parse(localStorage.getItem('daimond-trust-log'));
256 const keep = JSON.stringify(log);
257 log[0].t = (log[0].t || 0) + 1;
258 localStorage.setItem('daimond-trust-log', JSON.stringify(log));
259 const at = await window.DaimondTrust.chainBreak();
260 localStorage.setItem('daimond-trust-log', keep);
261 return at;
262 });
263 check('editing an entry in place breaks the chain, at a named position', broke === 0, `break at ${broke}`);
264
265 // Blocking, and its removal, are both APPENDED. Nothing in this log deletes.
266 const blocked = await A.page.evaluate(async k => {
267 const before = window.DaimondTrust.log().length;
268 await window.DaimondTrust.setBlocked(k, true);
269 const on = (await window.DaimondTrust.person(k)).state;
270 const onWords = window.DaimondTrust.keyWords(await window.DaimondTrust.person(k));
271 await window.DaimondTrust.setBlocked(k, false);
272 const off = (await window.DaimondTrust.person(k)).state;
273 return { on, off, onWords, grew: window.DaimondTrust.log().length - before,
274 chain: await window.DaimondTrust.chainBreak() };
275 }, B.key);
276 check('blocking a key shows on the row, and unblocking takes it off',
277 blocked.on === 'blocked' && blocked.off === 'matched', `"${blocked.onWords}"`);
278 check('both the block and its removal were APPENDED, not edited away',
279 blocked.grew === 2 && blocked.chain === -1, `the log grew by ${blocked.grew}`);
280
281 const noTools = await A.page.evaluate(async k => {
282 const before = window.DaimondTrust.log().length;
283 const got = await window.DaimondTrust.markMatched(k, 'tools');
284 return { got: got, grew: window.DaimondTrust.log().length - before };
285 }, B.key);
286 check('there is no TOOLS scope to write, and the attempt writes nothing',
287 noTools.got === null && noTools.grew === 0);
288
289 console.log(`\n── D. Decision 4: a lookup can never rise on its own ${'─'.repeat(17)}`);
290 // The offer a card gets depends entirely on how it arrived, and this reads
291 // the ACTUAL DIALOG — the buttons a person would see — rather than a flag
292 // that says which branch was taken.
293 const sUrl = await S.page.evaluate(() => window.DaimondTrust.cardUrl());
294 const dialogFor = (route) => A.page.evaluate(async ([u, r]) => {
295 document.querySelectorAll('.pair-scrim').forEach(n => n.remove());
296 if (r === 'link') {
297 // The arrival a phone's camera produces: the app opens at the URL and
298 // the hash handler takes it from there.
299 location.hash = u.slice(u.indexOf('#'));
300 window.dispatchEvent(new HashChangeEvent('hashchange'));
301 } else {
302 // The arrival THIS device's own camera produces. The scanner hands the
303 // text it read to the same door, with the route that says so.
304 window.DaimondTrust.offer(window.DaimondTrust.parse(u), r);
305 }
306 await new Promise(res => setTimeout(res, 400));
307 const box = document.querySelector('.pair-box');
308 const text = box ? box.textContent : '';
309 const buttons = box ? [...box.querySelectorAll('button')].map(b => b.textContent) : [];
310 document.querySelectorAll('.pair-scrim').forEach(n => n.remove());
311 return { text, buttons };
312 }, [sUrl, route]);
313
314 const linkOffer = await dialogFor('link');
315 check('a card arriving by link offers no "Mark matched now"',
316 linkOffer.buttons.length > 0 && linkOffer.buttons.every(b => !/matched now/i.test(b)),
317 `buttons: ${linkOffer.buttons.join(' | ') || 'NONE — the dialog did not open'}`);
318 check('and it offers the safety number instead',
319 linkOffer.buttons.some(b => /safety number/i.test(b)));
320 check('and it says why, in words', /middle/i.test(linkOffer.text));
321
322 const qrOffer = await dialogFor('qr');
323 check('a card this device\'s own camera read DOES offer it',
324 qrOffer.buttons.some(b => /matched now/i.test(b)),
325 `buttons: ${qrOffer.buttons.join(' | ') || 'NONE'}`);
326
327 // A third identity mints a card naming B's key as the one it supersedes —
328 // which is exactly what somebody who stole B's old key could also do. Minted
329 // now and used after E, because the rotation takes B out of the matched set
330 // and the look-alike below is a comparison against it.
331 const C = await device('trust-carol');
332 const rotated = await C.page.evaluate(async (prevHex) => {
333 const id = window.DaimondIdentity, b = window.DaimondCrypto;
334 const enc = id.sealingKeyRaw();
335 const prev = Uint8Array.from(prevHex.match(/../g).map(h => parseInt(h, 16)));
336 const payload = b.cardEncode('carol', enc, prev);
337 const author = await id.publicKeyRaw();
338 const when = Date.now();
339 const sigB64 = await id.sign(b.signingInput(payload, 'daimond/card/0', author, when));
340 const sig = Uint8Array.from(atob(sigB64), c => c.charCodeAt(0));
341 const art = b.assemble(payload, 'daimond/card/0', author, when, sig);
342 let s = '';
343 for (const byte of art) s += String.fromCharCode(byte);
344 return 'DMND-ID1.' + btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
345 }, B.key);
346
347 console.log(`\n── E. A look-alike warns, and never blocks ${'─'.repeat(27)}`);
348 const sCard = await S.page.evaluate(() => window.DaimondTrust.cardText());
349 const lookalike = await A.page.evaluate(async ([text, dk]) => {
350 const card = window.DaimondTrust.parse(text);
351 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.PASTE);
352 const p = await window.DaimondTrust.person(dk);
353 const folds = [window.DaimondTrust.fold('trust8'), window.DaimondTrust.fold('trustb')];
354 return p ? { state: p.state, warn: p.warn, warnFp: p.warnFp, label: p.label, folds } : null;
355 }, [sCard, S.key]);
356 check('a name that folds onto a matched one is flagged',
357 lookalike && lookalike.warn === 'lookalike',
358 lookalike ? `"${lookalike.label}" folds to ${lookalike.folds[0]}` : 'no person');
359 check('the folding is what caught it', lookalike && lookalike.folds[0] === lookalike.folds[1]);
360 check('and it is a WARNING: the key is still listed, still new',
361 lookalike && lookalike.state === 'new');
362
363 // And the other way of matching, which the list below then carries a row of:
364 // the two methods must read as two statements about an ACT, and neither may
365 // read as a statement about a person.
366 const byNumber = await A.page.evaluate(async k => {
367 await window.DaimondTrust.markMatched(k, window.DaimondTrust.METHOD.NUMBER);
368 const p = await window.DaimondTrust.person(k);
369 return p ? { state: p.state, method: p.method, words: window.DaimondTrust.keyWords(p) } : null;
370 }, S.key);
371 check('a key matched by safety number says so, and says only that',
372 byNumber && byNumber.state === 'matched' && /safety number/i.test(byNumber.words),
373 byNumber && byNumber.words);
374
375 console.log(`\n── F. Rotation is a claim, never a transfer ${'─'.repeat(26)}`);
376 const changed = await A.page.evaluate(async ([text, bk]) => {
377 const card = window.DaimondTrust.parse(text);
378 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.PASTE);
379 const p = await window.DaimondTrust.person(card.key);
380 const old = await window.DaimondTrust.person(bk);
381 return p ? {
382 state: p.state, key: p.key, prevKey: p.prevKey, chain: p.chain.length,
383 words: window.DaimondTrust.keyWords(p),
384 oldSame: old && old.key === p.key,
385 } : null;
386 }, [rotated, B.key]);
387 check('a card naming a matched key as its predecessor joins that chain',
388 changed && changed.chain === 2 && changed.prevKey === B.key);
389 check('the match does NOT carry across to the new key',
390 changed && changed.state === 'changed', changed ? `state "${changed.state}"` : 'none');
391 check('and the words say a different key, loudly',
392 changed && /different key/i.test(changed.words), changed && changed.words);
393 check('the old key now resolves to the same person', changed && changed.oldSame);
394 await C.close();
395
396 console.log(`\n── G. Two axes, and they never share a badge or a word ${'─'.repeat(15)}`);
397 // Somebody recorded and never matched, so the list carries all three states
398 // at once and the words for each can be read off one screen.
399 const N = await device('trust-nell');
400 const nCard = await N.page.evaluate(() => window.DaimondTrust.cardText());
401 await A.page.evaluate(async (text) => {
402 await window.DaimondTrust.record(window.DaimondTrust.parse(text),
403 window.DaimondTrust.ROUTE.LOOKUP);
404 }, nCard);
405 await N.close();
406 // Draw the real list into the real panel, then MEASURE it.
407 const drawn = await A.page.evaluate(async () => {
408 try { window.DaimondSocial.open('people'); } catch (e) { /* no panel */ }
409 await new Promise(r => setTimeout(r, 300));
410 await window.DaimondTrust.refresh();
411 await new Promise(r => setTimeout(r, 200));
412 const host = document.getElementById('social-people-list');
413 if (!host) return { host: false };
414 const rows = [...host.querySelectorAll('.trust-row')];
415 const out = rows.map(row => {
416 const name = row.querySelector('.trust-name, .trust-claim');
417 const line = row.querySelector('[data-key-state]');
418 if (!name || !line) return { bad: 'missing' };
419 const n = name.getBoundingClientRect(), l = line.getBoundingClientRect();
420 return {
421 state: line.getAttribute('data-key-state'),
422 words: line.textContent,
423 nameH: n.height, lineH: l.height,
424 below: l.top >= n.bottom - 1,
425 flushLeft: Math.abs(l.left - n.left) <= 2,
426 display: getComputedStyle(line).display,
427 };
428 });
429 return { host: true, rows: out };
430 });
431 check('the People list is drawn into the panel\'s own container', drawn.host && drawn.rows.length > 0,
432 drawn.host ? `${drawn.rows.length} rows` : '#social-people-list not found');
433 // A geometry check on a hidden panel measures nothing and passes on everything,
434 // so the rows are proved to have been ON SCREEN before anything is concluded.
435 const measurable = drawn.rows && drawn.rows.every(r => r.nameH > 0 && r.lineH > 0);
436 check('the rows were actually on screen when measured', measurable,
437 measurable ? '' : 'a rect was zero — the checks below would have been vacuous');
438 check('every row carries a key line', drawn.rows && drawn.rows.every(r => !r.bad));
439 check('the key line is a block, so nothing can sit beside it',
440 drawn.rows && drawn.rows.every(r => r.display === 'block'));
441 check('the key line is BELOW the name, never beside it',
442 measurable && drawn.rows.every(r => r.below));
443 check('and starts at the name\'s own left edge', measurable && drawn.rows.every(r => r.flushLeft));
444 const words = (drawn.rows || []).map(r => r.words).join(' | ');
445 check('no key line says "verified" or "trusted"',
446 !/verif|trust(ed)?\b/i.test(words), words);
447 check('an unmatched key says "new", not "unverified"',
448 drawn.rows.some(r => r.state === 'new' && /new key/i.test(r.words)));
449
450 // The guard, proved rather than assumed: a table that says the wrong thing
451 // must not reach the screen.
452 const guarded = await A.page.evaluate(() => {
453 const real = window.DaimondI18n.t;
454 window.DaimondI18n.t = function (k) {
455 if (k === 'trust.key_new') return 'Verified — you can trust this one';
456 return real.apply(this, arguments);
457 };
458 const node = window.DaimondTrust.drawKeyLine({ state: 'new' });
459 window.DaimondI18n.t = real;
460 return node.textContent;
461 });
462 check('a locale table saying "Verified" for a key is refused, not drawn',
463 !/verif/i.test(guarded), `drew "${guarded}"`);
464
465 // And the eight tables themselves, read from disk.
466 const FORBIDDEN = {
467 'en.js': ['verified', 'unverified', 'trusted', 'untrusted'],
468 'de.js': ['verifiziert', 'vertrauenswürdig', 'vertraut'],
469 'es.js': ['verificado', 'verificada', 'confiable'],
470 'fr.js': ['vérifié', 'vérifiée', 'certifié', 'confiance'],
471 'ja.js': ['認証済', '検証済', '信頼'],
472 'ko.js': ['인증됨', '검증됨', '신뢰'],
473 'pt-BR.js': ['verificado', 'verificada', 'confiável'],
474 'zh-Hans.js': ['已验证', '已认证', '可信', '信任'],
475 };
476 let offenders = [], defined = 0;
477 for (const file of Object.keys(FORBIDDEN)) {
478 const p = path.join(WWW, 'i18n', file);
479 if (!fs.existsSync(p)) continue;
480 for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
481 if (!/'trust\.key_/.test(line)) continue;
482 defined++;
483 const low = line.toLowerCase();
484 for (const w of FORBIDDEN[file]) {
485 if (low.indexOf(w.toLowerCase()) >= 0) offenders.push(`${file}: ${line.trim()}`);
486 }
487 }
488 }
489 check('no locale table spells a key state with the other axis\'s word',
490 offenders.length === 0,
491 defined ? `${defined} key-state strings across the eight files` : 'none defined yet (Lane F)');
492
493 console.log(`\n── H. Reachability ${'─'.repeat(50)}`);
494 const tagged = await A.page.evaluate(async () => {
495 const html = await (await fetch('/index.html')).text();
496 return new RegExp('<script[^>]+src=["\']js/trust\\.js["\']').test(html);
497 });
498 check('index.html loads js/trust.js (Lane F)', tagged,
499 tagged ? '' : 'NOT YET — everything above ran on an injected copy');
500 const seam = await A.page.evaluate(() => ({
501 social: !!window.DaimondSocial,
502 host: !!document.getElementById('social-people-list'),
503 off: (document.getElementById('social-people-off') || {}).hidden,
504 }));
505 check('the Social panel\'s People seam exists and was fed', seam.social && seam.host);
506 check('the panel\'s empty line came down once rows were drawn', seam.off === true,
507 `hidden=${seam.off}`);
508
509 await shot(A, 'trust-people');
510} finally {
511 for (const s of [A, B, S]) {
512 const errs = errors(s).filter(e => !/502|Bad Gateway|Failed to load resource/.test(e));
513 if (errs.length) console.log(` console errors (${s.name}):`, errs.slice(0, 5));
514 await s.close();
515 }
516}
517
518console.log(`\n${ok.length} ok, ${bad.length} failed`);
519if (bad.length) { bad.forEach(b => console.log(' FAIL ' + b)); process.exit(1); }