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. |
| 30 | import fs from 'node:fs'; |
| 31 | import path from 'node:path'; |
| 32 | import { fileURLToPath } from 'node:url'; |
| 33 | import { open, shot, errors } from './harness.mjs'; |
| 34 | import { GW_PORT, GW_URL } from './ports.mjs'; |
| 35 | |
| 36 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 37 | const WWW = path.join(HERE, '..', 'www'); |
| 38 | |
| 39 | const ok = [], bad = []; |
| 40 | const 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. |
| 46 | async 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". |
| 69 | let gatewayUp = false; |
| 70 | try { |
| 71 | const r = await fetch(`${GW_URL}/`, { signal: AbortSignal.timeout(800) }); |
| 72 | gatewayUp = !!r; |
| 73 | } catch (e) { gatewayUp = false; } |
| 74 | |
| 75 | const A = await device('trustb-a'); |
| 76 | const 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. |
| 79 | const S = await device('trust8'); // "trust8" folds onto "trustb": 8 → b |
| 80 | |
| 81 | try { |
| 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 | |
| 518 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 519 | if (bad.length) { bad.forEach(b => console.log(' FAIL ' + b)); process.exit(1); } |