Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/trust.js

49.5 KiB, 1 run

created by r2519314175:1461, 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 — first contact (trust.js)
3 ------------------------------------------------------------
4 How two people establish that they hold each other's real keys,
5 with NO SERVER IN THE PATH AT ALL. Everything in this file runs
6 between two devices in a room, or between two devices and a
7 voice call. The gateway is not consulted, and cannot be: it is
8 not a party to any of it.
9
10 ── THE RULE EVERYTHING ELSE HANGS ON ───────────────────────
11
12 A KEY THE GATEWAY HANDED YOU IS AN UNMATCHED KEY, FOR EVER,
13 UNTIL A HUMAN DOES SOMETHING OUT OF BAND.
14
15 A handle lookup is an asynchronous channel with an intermediary
16 in it, and such a channel's ceiling is "unmatched": a
17 man-in-the-middle substitutes its own key and re-signs
18 everything cleanly under it, so a card that verifies perfectly
19 proves the holder of that key composed it and NOTHING about who
20 the holder is. Treating a lookup as sufficient would make the
21 seal decorative. So there are exactly two ways up, both of them
22 acts a person performs:
23
24 * a card read off a screen by this device's own camera, in
25 person, where there is no channel to poison; and
26 * a safety number read aloud over a channel an attacker
27 cannot silently rewrite.
28
29 A card that arrives by a link, a paste or a lookup is recorded
30 and is never offered the first of those. It cannot be: nothing
31 about a pasted string says the two people were in a room.
32
33 ── TWO AXES, AND THEY MUST NEVER SHARE A BADGE OR A WORD ───
34
35 Key authenticity — *is this key the one held by the person I
36 think?* — and human reputation — *is this a real, unique person,
37 and what is their standing?* — are orthogonal, and all four
38 combinations occur. A single scale that mixes them is a lie in
39 two directions at once, and the dangerous direction is the one
40 where a high reputation makes an unmatched key look safe.
41
42 So, and this file exists to hold the line:
43
44 * The word about a KEY is **matched** or **new**. Never
45 "verified", never "trusted", in any surface, any locale,
46 any tooltip. "Verified" belongs to personhood and is
47 somebody else's claim; "trusted" is worse still, because it
48 also collides with a tools-permission scope, which is a
49 third thing again.
50 * The key state is drawn as a LINE UNDER THE NAME, never as a
51 badge beside it. Different POSITION, not merely different
52 colour: a line under a name and a badge beside it cannot
53 merge in a glance, and two badges side by side will.
54 * The key line is ALWAYS present. A personhood badge, when
55 there is ever one to draw, is present only when there is a
56 bound claim — so its absence is unremarkable and its
57 presence is not a prerequisite for anything.
58
59 `drawKeyLine` is the only place a key state reaches a screen,
60 and it refuses a string carrying either forbidden word rather
61 than drawing it. A rule kept by discipline is a rule that ships
62 broken in the seventh locale.
63
64 ── WHAT IS STORED ──────────────────────────────────────────
65
66 An append-only `trust.log` in this account's own storage, hash
67 chained so that a truncation or an edit is detectable rather
68 than merely discouraged. Three kinds of entry:
69
70 card a signed IdentityCard somebody handed us. Verified by
71 the format's own crate on the way in AND on every
72 replay; a card is a claim by its key and nothing more.
73 edge a signed TrustEdge — `{from, to, scope, method,
74 created, nonce, sig}` — which is the record of the act
75 a person performed. Scope is always IDENTITY. There is
76 no TOOLS arm and there must never be one: nothing is
77 installable from a message, so the escalation that
78 scope exists to fence does not arise here.
79 block a key this account will not hear from. An appended
80 fact, not a deletion, because the log only ever grows.
81
82 The People list is a REPLAYABLE PROJECTION of that log and
83 holds no state of its own. Every signature is checked on the
84 replay, so a log whose edge has been tampered with projects the
85 person back to "new" rather than quietly keeping a state it can
86 no longer justify.
87
88 ── ROTATION IS A CLAIM, NEVER A TRANSFER ───────────────────
89
90 A card may name the key it supersedes. That links the two into
91 one chain, and it does NOT carry a match across: the commonest
92 reason to rotate is that the old key leaked, and a certificate
93 signed by the old key is exactly what the leaker can also
94 produce. So a chain whose older key was matched and whose
95 current key was not is drawn loudly, and messages are held.
96 ============================================================ */
97(function () {
98 'use strict';
99
100 // ── What a person reads ────────────────────────────────────
101
102 /// A string from the table, or the English written here where the table has
103 /// no entry for it yet, so a sentence added before its translation reads as a
104 /// sentence and not as a key. The same device identity.js uses.
105 function t(k, v) { return window.DaimondI18n ? window.DaimondI18n.t(k, v) : k; }
106 function tOr(key, fallback, vars) {
107 var s = t(key, vars);
108 if (s !== key) return s;
109 if (!vars) return fallback;
110 return String(fallback).replace(/\{(\w+)\}/g, function (m, n) {
111 return (vars[n] === undefined) ? m : String(vars[n]);
112 });
113 }
114
115 /// The words a key state may never carry, per language.
116 ///
117 /// English first, because that is the fallback every locale falls back to and
118 /// the one a hurried edit is written in. The others are the renderings of
119 /// "verified" and "trusted" a translator would most naturally reach for; the
120 /// list is a NET, not a proof, and the property the verifier really leans on
121 /// is the geometric one — the line is under the name, so it cannot merge with
122 /// a badge whatever it says.
123 var FORBIDDEN = {
124 en: ['verified', 'unverified', 'trusted', 'untrusted', 'verify', 'trust'],
125 de: ['verifiziert', 'vertrauenswürdig', 'vertraut', 'bestätigt'],
126 es: ['verificado', 'verificada', 'confianza', 'confiable'],
127 fr: ['vérifié', 'vérifiée', 'confiance', 'certifié'],
128 ja: ['認証済', '検証済', '信頼'],
129 ko: ['인증됨', '검증됨', '신뢰'],
130 pt: ['verificado', 'verificada', 'confiável', 'confiança'],
131 zh: ['已验证', '已認證', '已认证', '可信', '信任'],
132 };
133
134 /// Whether a rendered key-state string carries a word from the other axis.
135 function saysTheWrongThing(s) {
136 var loc = '';
137 try { loc = (window.DaimondI18n && window.DaimondI18n.locale && window.DaimondI18n.locale()) || ''; }
138 catch (e) { loc = ''; }
139 var lists = [FORBIDDEN.en];
140 var head = String(loc || '').slice(0, 2).toLowerCase();
141 if (FORBIDDEN[head] && head !== 'en') lists.push(FORBIDDEN[head]);
142 var low = String(s).toLowerCase();
143 for (var i = 0; i < lists.length; i++) {
144 for (var j = 0; j < lists[i].length; j++) {
145 if (low.indexOf(lists[i][j]) >= 0) return lists[i][j];
146 }
147 }
148 return '';
149 }
150
151 // ── Encoding ───────────────────────────────────────────────
152
153 function utf8(s) { return new TextEncoder().encode(String(s)); }
154
155 function b64enc(buf) {
156 var b = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);
157 var s = '';
158 for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
159 return btoa(s);
160 }
161
162 function b64dec(str) {
163 var bin = atob(String(str));
164 var out = new Uint8Array(bin.length);
165 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
166 return out;
167 }
168
169 function b64url(b64) { return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
170
171 function unb64url(s) {
172 var v = String(s).replace(/-/g, '+').replace(/_/g, '/');
173 while (v.length % 4) v += '=';
174 return v;
175 }
176
177 function hex(bytes) {
178 var s = '';
179 for (var i = 0; i < bytes.length; i++) s += (bytes[i] + 256).toString(16).slice(1);
180 return s;
181 }
182
183 function unhex(s) {
184 var v = String(s || '');
185 var out = new Uint8Array(v.length >> 1);
186 for (var i = 0; i < out.length; i++) out[i] = parseInt(v.substr(i * 2, 2), 16);
187 return out;
188 }
189
190 function bytesEqual(a, b) {
191 if (!a || !b || a.length !== b.length) return false;
192 var diff = 0;
193 for (var i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
194 return diff === 0;
195 }
196
197 // ── The bridge ─────────────────────────────────────────────
198
199 /// The identity format's entry points, or null when the wasm module has not
200 /// brought them up. Nothing here computes a fingerprint, a safety number or a
201 /// card address itself: there is one implementation of each, in the crate
202 /// that owns the format, and a second written in JavaScript is how two
203 /// devices come to draw the same key differently.
204 function bridge() {
205 return (typeof window !== 'undefined' && window.DaimondCrypto) || null;
206 }
207
208 /// A verified card out of a whole artefact, or null. The WHOLE verification —
209 /// magic, envelope, tree length, address, signature — runs in the crate; what
210 /// comes back has passed every part of it.
211 function readCard(bytes) {
212 var b = bridge();
213 if (!b || typeof b.read !== 'function') return null;
214 var got;
215 try { got = JSON.parse(b.read(bytes)); }
216 catch (e) { return null; }
217 if (!got || got.kind !== 'card' || got.schema !== 'daimond/card/0') return null;
218 if (!got.author || !got.card || !got.card.enc) return null;
219 return {
220 key: got.author, // hex, 32 bytes: the signing key, and the identity
221 fp: got.fingerprint || '',
222 label: String(got.card.label || ''),
223 enc: got.card.enc,
224 prev: got.card.prev || '',
225 time: Number(got.time) || 0,
226 addr: got.address || '',
227 };
228 }
229
230 // ── The transports ─────────────────────────────────────────
231 //
232 // One artefact, three ways of carrying it, and the way it arrived is what
233 // decides the ceiling — see `ROUTE` below. The bytes are identical in all
234 // three; the difference is entirely about what the ROUTE proves, which is
235 // why the route travels with the card rather than being inferred later.
236
237 var PASTE_PREFIX = 'DMND-ID1.';
238 /// The fragment a card rides in a URL. A fragment never reaches a server by
239 /// construction, which is the whole reason it is one.
240 var HASH_KEY = 'c';
241
242 /// Where a card came from, and therefore how high it may go.
243 ///
244 /// `qr` is this device's own camera reading a screen in the room. Nothing
245 /// else is: a `DMND-ID1.` string in a chat window looks exactly the same
246 /// whether a friend or an intermediary put it there.
247 var ROUTE = { QR: 'qr', LINK: 'link', PASTE: 'paste', LOOKUP: 'lookup' };
248
249 /// This identity's card as the string that is pasted.
250 function cardText() {
251 var c = window.DaimondIdentity && window.DaimondIdentity.card();
252 return c ? (PASTE_PREFIX + b64url(c)) : '';
253 }
254
255 /// This identity's card as the URL a camera opens.
256 function cardUrl() {
257 var c = window.DaimondIdentity && window.DaimondIdentity.card();
258 return c ? (location.origin + location.pathname + '#' + HASH_KEY + '=' + b64url(c)) : '';
259 }
260
261 /// Read a card out of whatever was handed over: the paste form, the URL
262 /// form, or the bare base64url in either encoding. Answers the verified card
263 /// with its raw bytes, or null.
264 function parse(text) {
265 var s = String(text || '').trim();
266 if (!s) return null;
267 var m = /[#&]c=([A-Za-z0-9_\-=+/]+)/.exec(s);
268 if (m) s = m[1];
269 else if (s.slice(0, PASTE_PREFIX.length).toUpperCase() === PASTE_PREFIX) {
270 s = s.slice(PASTE_PREFIX.length);
271 }
272 s = s.replace(/\s+/g, '');
273 var bytes;
274 try { bytes = b64dec(unb64url(s)); }
275 catch (e) { return null; }
276 if (!bytes.length) return null;
277 var card = readCard(bytes);
278 if (!card) return null;
279 card.bytes = bytes;
280 return card;
281 }
282
283 // ── The log ────────────────────────────────────────────────
284
285 /// This account's trust log. Namespaced per account by accounts.js, which
286 /// shims localStorage, so nothing here has to know about accounts at all.
287 var LOG_KEY = 'daimond-trust-log';
288
289 /// Every scope a TrustEdge may carry. One, deliberately. Messaging writes
290 /// IDENTITY and never TOOLS: no tool is installable from a message, so the
291 /// escalation the second scope exists to fence does not arise, and neither
292 /// does the expiry that goes with it. Two states, not four.
293 var SCOPE_IDENTITY = 'identity';
294
295 /// How a key was matched. Both arms are statements about an act the user
296 /// performed on a KEY; neither could ever be read as a claim about a person.
297 var METHOD = { QR: 'in_person_qr', NUMBER: 'safety_number' };
298
299 function readLog() {
300 var raw = null;
301 try { raw = localStorage.getItem(LOG_KEY); } catch (e) { raw = null; }
302 if (!raw) return [];
303 var v = null;
304 try { v = JSON.parse(raw); } catch (e) { v = null; }
305 return Array.isArray(v) ? v : [];
306 }
307
308 function writeLog(entries) {
309 try { localStorage.setItem(LOG_KEY, JSON.stringify(entries)); return true; }
310 catch (e) { return false; }
311 }
312
313 /// The canonical bytes of a log entry, for the hash chain.
314 ///
315 /// Every variable-length field is length-prefixed, so no two different
316 /// entries can produce the same bytes by running one field's tail into the
317 /// next one's head. The chain hash of the entry before it goes in first,
318 /// which is what makes the chain a chain.
319 function entryBytes(prevHash, e) {
320 var parts = [];
321 var push = function (bytes) {
322 var n = bytes.length;
323 parts.push(new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]));
324 parts.push(bytes);
325 };
326 push(utf8('daimond-trust-log-v1'));
327 push(unhex(prevHash || ''));
328 push(utf8(String(e.k || '')));
329 push(u64be(Number(e.t) || 0));
330 // Whatever else the entry carries, in a fixed field order per kind, so
331 // the bytes are a function of the entry and not of key insertion order.
332 if (e.k === 'card') {
333 push(utf8(String(e.a || '')));
334 push(utf8(String(e.route || '')));
335 } else if (e.k === 'edge') {
336 push(utf8(String(e.from || '')));
337 push(utf8(String(e.to || '')));
338 push(utf8(String(e.scope || '')));
339 push(utf8(String(e.method || '')));
340 push(u64be(Number(e.created) || 0));
341 push(utf8(String(e.nonce || '')));
342 push(utf8(String(e.sig || '')));
343 } else if (e.k === 'block') {
344 push(utf8(String(e.key || '')));
345 push(utf8(e.on ? '1' : '0'));
346 }
347 var total = 0, i;
348 for (i = 0; i < parts.length; i++) total += parts[i].length;
349 var out = new Uint8Array(total);
350 var at = 0;
351 for (i = 0; i < parts.length; i++) { out.set(parts[i], at); at += parts[i].length; }
352 return out;
353 }
354
355 /// Eight big-endian bytes of a millisecond stamp. Split rather than taken
356 /// through a BigInt: a `u64` crossing into JavaScript is a `BigInt` every
357 /// caller then has to build, and a millisecond is nowhere near 2^53 anyway.
358 function u64be(ms) {
359 var hi = Math.floor(ms / 4294967296);
360 var lo = ms >>> 0;
361 return new Uint8Array([
362 (hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255,
363 (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255,
364 ]);
365 }
366
367 async function sha256hex(bytes) {
368 var d = await crypto.subtle.digest('SHA-256', bytes);
369 return hex(new Uint8Array(d));
370 }
371
372 /// Append one entry, chaining it to the one before. The log only ever grows:
373 /// this is the single writer, and it appends.
374 async function append(entry) {
375 var log = readLog();
376 var prev = log.length ? String(log[log.length - 1].h || '') : '';
377 entry.t = entry.t || Date.now();
378 entry.h = await sha256hex(entryBytes(prev, entry));
379 log.push(entry);
380 if (!writeLog(log)) return null;
381 projection = null; // the replay is stale now
382 return entry;
383 }
384
385 /// Where the chain first breaks, or -1 when it does not.
386 ///
387 /// A tampered or truncated log is not a log that quietly reads a little
388 /// differently: it is one whose every later entry fails to hash, so the break
389 /// has a position and the position is reported rather than the whole thing
390 /// being thrown away.
391 async function chainBreak() {
392 var log = readLog();
393 var prev = '';
394 for (var i = 0; i < log.length; i++) {
395 var e = log[i];
396 var want = await sha256hex(entryBytes(prev, e));
397 if (want !== String(e.h || '')) return i;
398 prev = want;
399 }
400 return -1;
401 }
402
403 // ── The signed TrustEdge ───────────────────────────────────
404
405 /// The bytes a TrustEdge is signed over.
406 ///
407 /// Domain-separated and wholly length-prefixed. This is a LOCAL canonical
408 /// form: an edge is a record of what this device's owner did, it is read back
409 /// by this device, and Phase 3 sends it nowhere. When there is a
410 /// `daimond/trust/0` SBJ schema in the format's own crate this should move
411 /// there and be the crate's canonical encoding, exactly as the card is —
412 /// see the report accompanying this file.
413 function edgeInput(edge) {
414 var parts = [];
415 var push = function (bytes) {
416 var n = bytes.length;
417 parts.push(new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]));
418 parts.push(bytes);
419 };
420 push(utf8('daimond-trust-edge-v1'));
421 push(unhex(edge.from));
422 push(unhex(edge.to));
423 push(utf8(edge.scope));
424 push(utf8(edge.method));
425 push(u64be(Number(edge.created) || 0));
426 push(b64dec(edge.nonce));
427 var total = 0, i;
428 for (i = 0; i < parts.length; i++) total += parts[i].length;
429 var out = new Uint8Array(total);
430 var at = 0;
431 for (i = 0; i < parts.length; i++) { out.set(parts[i], at); at += parts[i].length; }
432 return out;
433 }
434
435 /// Verify an Ed25519 signature under a raw public key.
436 ///
437 /// Here rather than in identity.js because identity.js has no such call: its
438 /// `verify` takes a passphrase and answers whether the wrapping key derives,
439 /// which is a different question entirely. A public-key signature check needs
440 /// no unlock and no secret, so it is safe to do from anywhere — but it is
441 /// generic, and it should be lifted into identity.js beside `sign`.
442 async function verifySig(pubHex, sigB64, data) {
443 try {
444 var key = await crypto.subtle.importKey('raw', unhex(pubHex), { name: 'Ed25519' }, false, ['verify']);
445 return await crypto.subtle.verify({ name: 'Ed25519' }, key, b64dec(sigB64), data);
446 } catch (e) {
447 return false;
448 }
449 }
450
451 /// The out-of-band act, recorded. `to` is the correspondent's full 32-byte
452 /// signing key as hex; `method` is one of METHOD.
453 ///
454 /// Signed by this device, so the log is not merely a note this device wrote
455 /// to itself: it is a statement this key made, and a log copied to another of
456 /// this account's devices carries its own proof.
457 async function markMatched(toHex, method) {
458 if (method !== METHOD.QR && method !== METHOD.NUMBER) return null;
459 var id = window.DaimondIdentity;
460 if (!id || !id.isUnlocked()) return null;
461 var pub = await id.publicKeyRaw();
462 if (!pub) return null;
463 var nonce = new Uint8Array(16);
464 crypto.getRandomValues(nonce);
465 var edge = {
466 k: 'edge',
467 from: hex(pub),
468 to: String(toHex || '').toLowerCase(),
469 scope: SCOPE_IDENTITY,
470 method: method,
471 created: Date.now(),
472 nonce: b64enc(nonce),
473 };
474 if (edge.to.length !== 64) return null;
475 if (edge.to === edge.from) return null; // an account does not match itself
476 edge.sig = await id.sign(edgeInput(edge));
477 return await append(edge);
478 }
479
480 /// Note a card. THIS IS NOT A MATCH and nothing about it raises anything:
481 /// it records that this key, with this label and this sealing subkey, was
482 /// handed over by this route at this moment.
483 async function record(card, route) {
484 if (!card || !card.bytes) return null;
485 return await append({ k: 'card', a: b64enc(card.bytes), route: route || ROUTE.PASTE });
486 }
487
488 /// Stop hearing from a key, or start again. An appended fact: the log does
489 /// not delete, so a block and its later removal are both in the record.
490 async function setBlocked(keyHex, on) {
491 return await append({ k: 'block', key: String(keyHex || '').toLowerCase(), on: !!on });
492 }
493
494 // ── The projection ─────────────────────────────────────────
495 //
496 // The People list is a replay of the log and holds nothing of its own. Every
497 // signature is checked HERE, on every replay — the card by the format's own
498 // crate, the edge by WebCrypto — so a log whose edge has been edited projects
499 // the person back to "new" rather than keeping a state it can no longer
500 // justify. An assertion that merely read `edge.method` would pass on a log
501 // with the signature bytes scribbled out.
502
503 var projection = null;
504
505 /// Fold a label to what a confusable-blind eye sees.
506 ///
507 /// Compatibility decomposition, combining marks removed, case folded, the
508 /// handful of shapes that carry across scripts mapped to one, and everything
509 /// that is not a letter or a digit dropped. It only ever raises a WARNING:
510 /// two people are allowed to be called Ada, and a normalisation that blocked
511 /// would be a normalisation that decided who may exist.
512 var CONFUSABLE = {
513 '0': 'o', '1': 'l', 'i': 'l', '5': 's', '2': 'z', '8': 'b', '6': 'g',
514 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
515 'у': 'y', 'х': 'x', 'і': 'l', 'ѕ': 's',
516 'ο': 'o', 'α': 'a', 'ε': 'e', 'ρ': 'p', 'ν': 'v',
517 };
518
519 function fold(label) {
520 var s = String(label || '');
521 try { s = s.normalize('NFKD').replace(/[̀-ͯ]/g, ''); } catch (e) { /* no ICU */ }
522 s = s.toLowerCase();
523 var out = '';
524 for (var i = 0; i < s.length; i++) {
525 var c = s[i];
526 if (CONFUSABLE[c]) c = CONFUSABLE[c];
527 if (/[a-z0-9]/.test(c)) out += c;
528 }
529 // Two shapes that are pairs of letters rather than single ones.
530 out = out.replace(/rn/g, 'm').replace(/vv/g, 'w').replace(/cl/g, 'd');
531 return out;
532 }
533
534 /// Replay the log into people. Cached, and thrown away by every append.
535 async function people() {
536 if (projection) return projection;
537 var log = readLog();
538 var mine = '';
539 try {
540 var pub = await (window.DaimondIdentity ? window.DaimondIdentity.publicKeyRaw() : null);
541 if (pub) mine = hex(pub);
542 } catch (e) { mine = ''; }
543
544 var cards = {}; // key hex -> the latest verified card for that key
545 var edges = {}; // key hex -> the verified edge that matched it
546 var blocks = {}; // key hex -> whether the latest block entry is on
547 var i;
548
549 for (i = 0; i < log.length; i++) {
550 var e = log[i];
551 if (!e || typeof e !== 'object') continue;
552 if (e.k === 'card') {
553 // Verified AGAIN, on the replay. A card sitting in storage is a
554 // file an attacker with the disk can edit, and one whose signature
555 // no longer checks is not a weaker claim, it is no claim.
556 var bytes;
557 try { bytes = b64dec(String(e.a || '')); } catch (err) { continue; }
558 var c = readCard(bytes);
559 if (!c) continue;
560 var held = cards[c.key];
561 if (!held || c.time >= held.time) {
562 c.route = e.route || ROUTE.PASTE;
563 c.seen = e.t || 0;
564 c.first = held ? held.first : (e.t || 0);
565 cards[c.key] = c;
566 }
567 } else if (e.k === 'edge') {
568 if (e.scope !== SCOPE_IDENTITY) continue; // no other scope exists
569 if (mine && String(e.from).toLowerCase() !== mine) continue; // not this account's act
570 var okSig = await verifySig(e.from, e.sig, edgeInput(e));
571 if (!okSig) continue;
572 var to = String(e.to).toLowerCase();
573 if (!edges[to] || (e.created || 0) > (edges[to].created || 0)) {
574 edges[to] = { method: e.method, created: e.created || e.t || 0 };
575 }
576 } else if (e.k === 'block') {
577 blocks[String(e.key).toLowerCase()] = !!e.on;
578 }
579 }
580
581 // Chains. A card may name the key it supersedes, which links the two into
582 // one person; the chain's root is the oldest key we hold a link back to.
583 var parent = {};
584 var k;
585 for (k in cards) {
586 if (!Object.prototype.hasOwnProperty.call(cards, k)) continue;
587 var prev = String(cards[k].prev || '').toLowerCase();
588 if (prev && cards[prev]) parent[k] = prev;
589 }
590 var rootOf = function (key) {
591 var seen = {};
592 var at = key;
593 while (parent[at] && !seen[at]) { seen[at] = 1; at = parent[at]; }
594 return at;
595 };
596
597 var groups = {};
598 for (k in cards) {
599 if (!Object.prototype.hasOwnProperty.call(cards, k)) continue;
600 var r = rootOf(k);
601 (groups[r] = groups[r] || []).push(cards[k]);
602 }
603
604 var out = [];
605 for (var g in groups) {
606 if (!Object.prototype.hasOwnProperty.call(groups, g)) continue;
607 var chain = groups[g].slice().sort(function (a, b) { return a.time - b.time; });
608 var current = chain[chain.length - 1];
609 var edge = edges[current.key] || null;
610 // A match on an OLDER key does not carry across, and this is the whole
611 // of 12.4.5: rotation is a claim, and a certificate signed by the old
612 // key is exactly what somebody who stole the old key can also produce.
613 var older = null;
614 for (i = 0; i < chain.length - 1; i++) {
615 if (edges[chain[i].key]) older = { key: chain[i].key, at: edges[chain[i].key] };
616 }
617 var state = 'new';
618 if (blocks[current.key]) state = 'blocked';
619 else if (edge) state = 'matched';
620 else if (older) state = 'changed';
621 out.push({
622 key: current.key,
623 fp: current.fp,
624 label: current.label,
625 enc: current.enc,
626 route: current.route,
627 first: current.first || current.seen,
628 seen: current.seen,
629 chain: chain.map(function (c) { return c.key; }),
630 state: state,
631 method: edge ? edge.method : '',
632 matchedAt: edge ? edge.created : 0,
633 prevKey: older ? older.key : '',
634 prevAt: older ? older.at.created : 0,
635 warn: '',
636 warnFp: '',
637 });
638 }
639
640 // Look-alikes, once every person is known: an unmatched label that folds
641 // onto a matched one is the shape of an impersonation, and it is a warning
642 // on the row and never an automatic block.
643 var byFold = {};
644 for (i = 0; i < out.length; i++) {
645 if (out[i].state !== 'matched') continue;
646 (byFold[fold(out[i].label)] = byFold[fold(out[i].label)] || []).push(out[i]);
647 }
648 for (i = 0; i < out.length; i++) {
649 if (out[i].state === 'matched') continue;
650 var twins = byFold[fold(out[i].label)];
651 if (!twins || !twins.length) continue;
652 if (twins.length === 1 && twins[0].key === out[i].key) continue;
653 out[i].warn = 'lookalike';
654 out[i].warnFp = twins[0].fp;
655 }
656
657 out.sort(function (a, b) { return (b.seen || 0) - (a.seen || 0); });
658 projection = out;
659 return out;
660 }
661
662 /// One person by their current key, or null.
663 async function person(keyHex) {
664 var all = await people();
665 var want = String(keyHex || '').toLowerCase();
666 for (var i = 0; i < all.length; i++) {
667 if (all[i].key === want) return all[i];
668 if (all[i].chain.indexOf(want) >= 0) return all[i];
669 }
670 return null;
671 }
672
673 // ── The safety number ──────────────────────────────────────
674
675 /// The number this account and one correspondent read to each other.
676 ///
677 /// The WHOLE 256-bit digest, sixty decimal digits in twelve groups of five,
678 /// computed by the format's own crate over both keys sorted — so both parties
679 /// get the same number without having to agree who is first. Not truncated,
680 /// and the reason is not aesthetic: the attack is a meet-in-the-middle costing
681 /// about 2^(n/2), so a number cut to 120 bits would face a 60-bit search.
682 async function safetyNumber(otherKeyHex) {
683 var b = bridge();
684 var id = window.DaimondIdentity;
685 if (!b || typeof b.safetyNumber !== 'function' || !id) return '';
686 var mine = await id.publicKeyRaw();
687 if (!mine) return '';
688 var theirs = unhex(String(otherKeyHex || '').toLowerCase());
689 if (theirs.length !== 32) return '';
690 try { return b.safetyNumber(mine, theirs); }
691 catch (e) { return ''; }
692 }
693
694 // ── Drawing a key state, and the only place it happens ─────
695
696 /// The sentence for a person's key state.
697 function keyWords(p) {
698 var when = p.matchedAt ? shortDate(p.matchedAt) : '';
699 if (p.state === 'blocked') return tOr('trust.key_blocked', 'Blocked key');
700 if (p.state === 'matched') {
701 return (p.method === METHOD.QR)
702 ? tOr('trust.key_matched_qr', 'Key matched in person, {when}', { when: when })
703 : tOr('trust.key_matched_number', 'Key matched by safety number, {when}', { when: when });
704 }
705 if (p.state === 'changed') {
706 return tOr('trust.key_changed',
707 'Different key — the one you matched was last seen {when}',
708 { when: shortDate(p.prevAt) });
709 }
710 return tOr('trust.key_new', 'New key — you have not matched this one');
711 }
712
713 /// Draw a person's key state UNDER their name. The only route a key state
714 /// takes to a screen.
715 ///
716 /// A LINE, not a badge, and the element is a block so it cannot end up beside
717 /// anything. If a table hands back a string carrying the other axis's word —
718 /// in any locale — the English is drawn instead and the fault is said out
719 /// loud, because a wrong word here is precisely the failure the two-axis rule
720 /// exists to prevent, and it is the kind nobody notices.
721 function drawKeyLine(p) {
722 var words = keyWords(p);
723 var wrong = saysTheWrongThing(words);
724 if (wrong) {
725 try {
726 console.warn('Daimond: a key-state string carried the word "' + wrong
727 + '", which belongs to the personhood axis and never to a key. '
728 + 'The English is drawn instead. Fix the locale table.');
729 } catch (e) { /* no console */ }
730 words = ({
731 blocked: 'Blocked key',
732 matched: 'Key matched',
733 changed: 'Different key from the one you matched',
734 })[p.state] || 'New key — you have not matched this one';
735 }
736 var line = document.createElement('div');
737 line.className = 'trust-keyline trust-key-' + p.state;
738 line.setAttribute('data-key-state', p.state);
739 line.textContent = words;
740 return line;
741 }
742
743 function shortDate(ms) {
744 if (!ms) return '';
745 var loc = 'en';
746 try { loc = (window.DaimondI18n && window.DaimondI18n.locale && window.DaimondI18n.locale()) || 'en'; }
747 catch (e) { loc = 'en'; }
748 try { return new Date(ms).toLocaleDateString(loc, { day: 'numeric', month: 'long' }); }
749 catch (e) { return new Date(ms).toDateString(); }
750 }
751
752 // ── The scanner, loaded when it is wanted ──────────────────
753
754 var scannerLoading = null;
755
756 /// Bring up the QR reader. Loaded on demand rather than at boot: it is forty
757 /// kilobytes that only a person opening the scanner ever needs, and the boot
758 /// has enough to do.
759 function scanner() {
760 if (window.DaimondQRScan) return Promise.resolve(window.DaimondQRScan);
761 if (scannerLoading) return scannerLoading;
762 scannerLoading = new Promise(function (done) {
763 var s = document.createElement('script');
764 s.src = 'js/qrscan.js';
765 s.onload = function () { done(window.DaimondQRScan || null); };
766 s.onerror = function () { done(null); };
767 document.head.appendChild(s);
768 });
769 return scannerLoading;
770 }
771
772 // ── The surfaces ───────────────────────────────────────────
773
774 function el(tag, cls, text) {
775 var e = document.createElement(tag);
776 if (cls) e.className = cls;
777 if (text != null) e.textContent = text;
778 return e;
779 }
780
781 /// The dialog frame, borrowed from pairing.js rather than written twice.
782 ///
783 /// Said out loud when it is not there, because a dialog that silently never
784 /// opens is a feature that looks absent rather than broken, and this file's
785 /// every surface goes through it.
786 function overlay(build) {
787 injectStyles();
788 if (window.DaimondPairing && window.DaimondPairing.ui && window.DaimondPairing.ui.overlay) {
789 return window.DaimondPairing.ui.overlay(build);
790 }
791 try {
792 console.warn('Daimond: js/pairing.js is not loaded, so the People surfaces have no '
793 + 'dialog frame to open in. Load it before js/trust.js.');
794 } catch (e) { /* no console */ }
795 return null;
796 }
797
798 function qrCanvas(text) {
799 if (window.DaimondPairing && window.DaimondPairing.ui && window.DaimondPairing.ui.qrCanvas) {
800 return window.DaimondPairing.ui.qrCanvas(text);
801 }
802 return null;
803 }
804
805 function injectStyles() {
806 if (document.getElementById('trust-styles')) return;
807 var s = document.createElement('style');
808 s.id = 'trust-styles';
809 s.textContent =
810 '.trust-row{padding:10px 0;border-bottom:1px solid var(--border,#333)}' +
811 '.trust-row:last-child{border-bottom:0}' +
812 // The name, and then the key line UNDER it. `display:block` on the line
813 // is doing real work: it is what makes the two impossible to draw side
814 // by side, whatever a later stylesheet does to the colours.
815 '.trust-name{display:block;font-size:var(--fs-base);font-weight:600}' +
816 '.trust-claim{display:block;font-size:var(--fs-base);opacity:.9}' +
817 '.trust-keyline{display:block;font-size:var(--fs-sm);opacity:.85;margin:2px 0 0}' +
818 '.trust-key-matched{color:var(--ok,#5b8)}' +
819 '.trust-key-new{opacity:.7}' +
820 '.trust-key-changed{color:var(--danger)}' +
821 '.trust-key-blocked{color:var(--danger)}' +
822 '.trust-fp{display:block;font-family:ui-monospace,monospace;font-size:var(--fs-xs);' +
823 'opacity:.7;letter-spacing:.04em;margin:2px 0 0}' +
824 '.trust-warn{display:block;font-size:var(--fs-sm);color:var(--danger);margin:4px 0 0}' +
825 '.trust-acts{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 0}' +
826 '.trust-list{max-height:52vh;overflow:auto;margin:0 0 12px}' +
827 // Sixty digits in twelve groups of five, three to a row. Monospace and
828 // generously spaced because the whole point is reading it aloud to
829 // somebody without losing your place.
830 '.trust-number{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 10px;' +
831 'font-family:ui-monospace,monospace;font-size:var(--fs-xl);text-align:center;' +
832 'padding:12px;border:1px dashed var(--border,#444);border-radius:8px;margin:0 0 12px;' +
833 'user-select:all}' +
834 '.trust-empty{opacity:.7;font-size:var(--fs-base);margin:0 0 12px}' +
835 '.trust-scan{display:block;width:100%;max-width:320px;margin:0 auto 12px;border-radius:8px;' +
836 'background:#000}';
837 document.head.appendChild(s);
838 }
839
840 /// One row of the People list. The order is deliberate and it is the
841 /// 2026-07-16 design's: what is known about the KEY comes before what the
842 /// card CLAIMS about the person, and the fingerprint is on the row rather
843 /// than behind a tap.
844 function personRow(p, onChange) {
845 var row = el('div', 'trust-row');
846 // The label is advisory. On a key nobody has matched it is drawn as the
847 // claim it is; on one that has been matched in person or by number, the
848 // person on the other end is known and the name may be their name.
849 var name = (p.state === 'matched')
850 ? el('div', 'trust-name', p.label || tOr('trust.no_name', '(no name given)'))
851 : el('div', 'trust-claim', tOr('trust.calls_themselves', 'calls themselves “{name}”',
852 { name: p.label || '—' }));
853 row.appendChild(name);
854 row.appendChild(drawKeyLine(p));
855 row.appendChild(el('div', 'trust-fp', p.fp));
856 if (p.warn === 'lookalike') {
857 row.appendChild(el('div', 'trust-warn',
858 tOr('trust.lookalike',
859 'This name looks like one you have already matched ({fp}). Names are not identities — check the key.',
860 { fp: p.warnFp })));
861 }
862 if (p.state === 'changed') {
863 row.appendChild(el('div', 'trust-warn',
864 tOr('trust.held', 'Messages from this key are held until you decide.')));
865 }
866 var acts = el('div', 'trust-acts');
867 if (p.state !== 'blocked') {
868 var num = el('button', 'pair-btn ghost', tOr('trust.compare_numbers', 'Compare safety numbers'));
869 num.addEventListener('click', function () { showSafety(p.key, onChange); });
870 acts.appendChild(num);
871 }
872 var blk = el('button', 'pair-btn ghost',
873 p.state === 'blocked' ? tOr('trust.unblock', 'Unblock') : tOr('trust.block', 'Block'));
874 blk.addEventListener('click', function () {
875 setBlocked(p.key, p.state !== 'blocked').then(function () { if (onChange) onChange(); });
876 });
877 acts.appendChild(blk);
878 row.appendChild(acts);
879 return row;
880 }
881
882 /// Show the People view of the Social panel, which is where the list lives.
883 function showPeople() {
884 try {
885 if (window.DaimondSocial && window.DaimondSocial.open) {
886 window.DaimondSocial.open('people');
887 return true;
888 }
889 } catch (e) { /* the panel is not up */ }
890 return false;
891 }
892
893 /// Show this identity's own card, as a symbol and as a string.
894 function showCard() {
895 overlay(function (box) {
896 box.appendChild(el('h3', null, tOr('trust.show_mine', 'Show my code')));
897 var id = window.DaimondIdentity;
898 var p = el('p', null, tOr('trust.show_lead',
899 'Let them point their camera at this. Reading it in person is the only way either of you can mark the other matched without a phone call.'));
900 box.appendChild(p);
901 var mint = (id && id.card()) ? Promise.resolve({ ok: true }) : (id ? id.mintCard() : Promise.resolve({ ok: false }));
902 mint.then(function () {
903 var url = cardUrl();
904 if (!url) {
905 box.appendChild(el('p', 'pair-err', tOr('trust.no_card',
906 'This device has no card yet. Unlock it and try again.')));
907 return;
908 }
909 var qr = qrCanvas(url);
910 if (qr) box.appendChild(qr);
911 var fp = el('div', 'trust-fp', (id && id.fingerprint()) || '');
912 box.appendChild(fp);
913 var txt = el('textarea', 'pair-name');
914 txt.value = cardText();
915 txt.rows = 3;
916 txt.readOnly = true;
917 box.appendChild(el('p', 'pair-note', tOr('trust.paste_lead',
918 'No camera? Send them this instead. A code that arrives this way is a new key and stays one until you compare numbers.')));
919 box.appendChild(txt);
920 });
921 });
922 }
923
924 /// Take somebody's card: by camera, or by paste.
925 function showAdd() {
926 overlay(function (box, close) {
927 box.appendChild(el('h3', null, tOr('trust.add', 'Add somebody')));
928 box.appendChild(el('p', null, tOr('trust.add_lead',
929 'Point this device at their code, or paste what they sent you.')));
930 var video = document.createElement('video');
931 video.className = 'trust-scan';
932 video.setAttribute('playsinline', '');
933 video.muted = true;
934 box.appendChild(video);
935 var err = el('div', 'pair-err');
936 var input = el('textarea', 'pair-name');
937 input.rows = 3;
938 input.setAttribute('placeholder', PASTE_PREFIX + '…');
939 box.appendChild(input);
940 box.appendChild(err);
941 var row = el('div', 'pair-row');
942 var cancel = el('button', 'pair-btn ghost', t('common.cancel'));
943 cancel.addEventListener('click', function () { stop(); close(); });
944 var go = el('button', 'pair-btn', tOr('trust.read_paste', 'Read this'));
945 row.appendChild(cancel);
946 row.appendChild(go);
947 box.appendChild(row);
948
949 var stream = null;
950 var timer = 0;
951 function stop() {
952 if (timer) { clearInterval(timer); timer = 0; }
953 if (stream) {
954 try { stream.getTracks().forEach(function (tr) { tr.stop(); }); } catch (e) {}
955 stream = null;
956 }
957 }
958 go.addEventListener('click', function () {
959 var card = parse(input.value);
960 if (!card) { err.textContent = tOr('trust.bad_card', 'That is not a Daimond code, or it did not verify.'); return; }
961 stop();
962 close();
963 // A PASTE, so the ceiling is the safety number and the in-person
964 // offer is not made. Nothing about a string in a chat window says
965 // the two of you were in a room.
966 accept(card, ROUTE.PASTE);
967 });
968
969 // The camera, where there is one. Its absence is not an error: the
970 // paste box above is the whole feature on a machine with no camera.
971 scanner().then(function (qr) {
972 if (!qr || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
973 video.style.display = 'none';
974 return;
975 }
976 navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
977 .then(function (st) {
978 stream = st;
979 video.srcObject = st;
980 video.play().catch(function () { /* the user will use the paste box */ });
981 timer = setInterval(function () {
982 qr.detect(video).then(function (hit) {
983 if (!hit || !hit.text) return;
984 var card = parse(hit.text);
985 if (!card) return;
986 stop();
987 close();
988 // READ BY THIS DEVICE'S OWN CAMERA, so the two of
989 // them are in a room and the in-person offer stands.
990 accept(card, ROUTE.QR);
991 });
992 }, 250);
993 })
994 .catch(function () { video.style.display = 'none'; });
995 });
996 });
997 }
998
999 /// What a card that has just arrived offers, which depends entirely on how
1000 /// it arrived. This is decision 4, drawn.
1001 function accept(card, route) {
1002 return record(card, route).then(function () {
1003 return person(card.key);
1004 }).then(function (p) {
1005 // What this key ALREADY is, rather than "new" every time. A card from
1006 // somebody matched last week is not news, and a card that turns out to
1007 // supersede a matched key is the loud case in 12.4.5 -- neither may be
1008 // drawn as a first meeting.
1009 var state = p || { state: 'new', method: '', matchedAt: 0, prevAt: 0 };
1010 overlay(function (box, close) {
1011 box.appendChild(el('h3', null, tOr('trust.arrived', 'A code arrived')));
1012 box.appendChild(el('div', 'trust-claim',
1013 tOr('trust.calls_themselves', 'calls themselves “{name}”', { name: card.label || '—' })));
1014 box.appendChild(drawKeyLine(state));
1015 box.appendChild(el('div', 'trust-fp', card.fp));
1016 var row = el('div', 'pair-row');
1017 if (route === ROUTE.QR && state.state !== 'matched') {
1018 box.appendChild(el('p', 'pair-note', tOr('trust.qr_lead',
1019 'You read this off their screen, so there was no channel for anybody to get between you. Mark it matched only if that is what happened.')));
1020 var mk = el('button', 'pair-btn', tOr('trust.mark_matched', 'Mark matched now'));
1021 mk.addEventListener('click', function () {
1022 markMatched(card.key, METHOD.QR).then(function () {
1023 close();
1024 if (redraw) redraw();
1025 showPeople();
1026 });
1027 });
1028 row.appendChild(mk);
1029 } else if (state.state !== 'matched') {
1030 // THE CEILING FOR EVERY ASYNCHRONOUS ROUTE, and it is the root of
1031 // the model rather than a caution: whatever handed this over could
1032 // have substituted its own key and signed the result perfectly.
1033 box.appendChild(el('p', 'pair-note', tOr('trust.async_lead',
1034 'This came through something in the middle, so it stays a new key. Read the safety number aloud on a call to change that.')));
1035 }
1036 var num = el('button', 'pair-btn ghost', tOr('trust.compare_numbers', 'Compare safety numbers'));
1037 num.addEventListener('click', function () { close(); showSafety(card.key); });
1038 row.appendChild(num);
1039 box.appendChild(row);
1040 });
1041 });
1042 }
1043
1044 /// The safety-number screen: sixty digits, and two people reading them.
1045 function showSafety(keyHex, onChange) {
1046 overlay(function (box, close) {
1047 box.appendChild(el('h3', null, tOr('trust.numbers', 'Safety numbers')));
1048 box.appendChild(el('p', null, tOr('trust.numbers_lead',
1049 'Read these sixty digits to each other on a call, or in person. Both of you must see the same twelve groups. Reading them in a message proves nothing — whatever could swap the keys could swap the message.')));
1050 var grid = el('div', 'trust-number');
1051 box.appendChild(grid);
1052 var err = el('div', 'pair-err');
1053 box.appendChild(err);
1054 var row = el('div', 'pair-row');
1055 var no = el('button', 'pair-btn ghost', tOr('trust.numbers_differ', 'They are different'));
1056 var yes = el('button', 'pair-btn', tOr('trust.numbers_match', 'The numbers match'));
1057 row.appendChild(no);
1058 row.appendChild(yes);
1059 box.appendChild(row);
1060
1061 safetyNumber(keyHex).then(function (n) {
1062 if (!n) { err.textContent = tOr('trust.no_number', 'This device cannot compute the number yet.'); return; }
1063 var groups = n.split(/\s+/);
1064 for (var i = 0; i < groups.length; i++) grid.appendChild(el('span', null, groups[i]));
1065 });
1066 no.addEventListener('click', function () {
1067 grid.style.display = 'none';
1068 err.textContent = tOr('trust.numbers_differ_note',
1069 'Then somebody is between you. Do not use this key. Meet, or start again from a code you read in person.');
1070 yes.disabled = true;
1071 });
1072 yes.addEventListener('click', function () {
1073 markMatched(keyHex, METHOD.NUMBER).then(function (e) {
1074 close();
1075 if (onChange) onChange();
1076 else if (redraw) redraw();
1077 if (e) showPeople();
1078 });
1079 });
1080 });
1081 }
1082
1083 // ── A card arriving in the URL ─────────────────────────────
1084
1085 /// The card carried in `#c=`, if this load came from a scanned symbol.
1086 function pendingCard() {
1087 var m = /[#&]c=([^&]+)/.exec(location.hash || '');
1088 return m ? decodeURIComponent(m[1]) : '';
1089 }
1090
1091 /// Take it out of the URL, so a reload does not re-offer it and it does not
1092 /// sit in history.
1093 function consumeHash() {
1094 try {
1095 var h = (location.hash || '').replace(/[#&]?c=[^&]*/, '');
1096 if (h === '#') h = '';
1097 history.replaceState({}, '', location.pathname + location.search + h);
1098 } catch (e) { /* nothing to tidy */ }
1099 }
1100
1101 function maybeOpenFromHash() {
1102 var raw = pendingCard();
1103 if (!raw) return;
1104 if (document.querySelector('.pair-scrim')) return; // a dialog is already up
1105 consumeHash();
1106 var card = parse(raw);
1107 if (!card) return;
1108 // A LINK, whatever put it there. A phone's camera opening this URL is not
1109 // this app's camera reading a screen, and the app cannot tell the two
1110 // apart — so it takes the lower of the two, which is the only safe way
1111 // round for a thing that cannot be told apart.
1112 accept(card, ROUTE.LINK);
1113 }
1114
1115 // ── Where a person finds this ──────────────────────────────
1116
1117 /// Draw the People list into a container. `#social-people-list` in the
1118 /// shipped panel, which is the seam improve.js left for exactly this: a lane
1119 /// renders rows and then says how many, and the panel takes its own honest
1120 /// empty line down.
1121 ///
1122 /// Answers the redraw function, so whatever mounted it can ask for the list
1123 /// again after an act that changes it.
1124 function mount(host) {
1125 if (!host) return null;
1126 injectStyles();
1127 host.innerHTML = '';
1128 var acts = el('div', 'trust-acts');
1129 var mine = el('button', 'pair-btn ghost', tOr('trust.show_mine', 'Show my code'));
1130 mine.addEventListener('click', showCard);
1131 var add = el('button', 'pair-btn', tOr('trust.add', 'Add somebody'));
1132 add.addEventListener('click', showAdd);
1133 acts.appendChild(mine);
1134 acts.appendChild(add);
1135 host.appendChild(acts);
1136 var list = el('div', 'trust-list');
1137 host.appendChild(list);
1138 var draw = function () {
1139 return people().then(function (all) {
1140 list.innerHTML = '';
1141 for (var i = 0; i < all.length; i++) list.appendChild(personRow(all[i], draw));
1142 // The panel's own empty line goes when there is a row to see. Two
1143 // buttons are not a row: a list with nobody in it still wants the
1144 // sentence saying so.
1145 try {
1146 if (window.DaimondSocial && window.DaimondSocial.filled) {
1147 window.DaimondSocial.filled('people', all.length);
1148 }
1149 } catch (e) { /* the panel is not up */ }
1150 return all.length;
1151 });
1152 };
1153 draw();
1154 return draw;
1155 }
1156
1157 /// Put the list in the Social panel and keep it in step with the panel.
1158 ///
1159 /// Read LAZILY, on the open, because that is when somebody is looking, and
1160 /// because the replay verifies every signature in the log — work with no
1161 /// business happening on a boot nobody asked it of.
1162 var redraw = null;
1163 function attachPanel() {
1164 var host = document.getElementById('social-people-list');
1165 if (!host) return false;
1166 redraw = mount(host);
1167 try {
1168 if (window.DaimondSocial && window.DaimondSocial.watch) {
1169 window.DaimondSocial.watch(function (view) {
1170 if (view === 'people' && redraw) redraw();
1171 });
1172 }
1173 } catch (e) { /* no panel to watch */ }
1174 return true;
1175 }
1176
1177 function start() {
1178 injectStyles();
1179 if (!attachPanel()) {
1180 // The panel is built by another module; if this ran first, wait for the
1181 // document rather than deciding there is no panel.
1182 document.addEventListener('DOMContentLoaded', attachPanel);
1183 }
1184 maybeOpenFromHash();
1185 window.addEventListener('hashchange', maybeOpenFromHash);
1186 }
1187
1188 // ── Public surface ─────────────────────────────────────────
1189 window.DaimondTrust = {
1190 // The transports. One artefact, three carriers, and the carrier is what
1191 // decides the ceiling.
1192 cardText: cardText,
1193 cardUrl: cardUrl,
1194 parse: parse,
1195 ROUTE: ROUTE,
1196 METHOD: METHOD,
1197 SCOPE: SCOPE_IDENTITY,
1198 // The log, and the acts that write to it.
1199 log: readLog,
1200 record: record,
1201 markMatched: markMatched,
1202 setBlocked: setBlocked,
1203 chainBreak: chainBreak,
1204 edgeInput: edgeInput,
1205 // The projection. `people` REPLAYS and re-verifies; it holds nothing.
1206 people: people,
1207 person: person,
1208 fold: fold,
1209 forget: function () { projection = null; },
1210 // The number, and the words.
1211 safetyNumber: safetyNumber,
1212 keyWords: keyWords,
1213 drawKeyLine: drawKeyLine,
1214 // The surfaces.
1215 showPeople: showPeople,
1216 showCard: showCard,
1217 showAdd: showAdd,
1218 /// What a card that has just arrived is offered, given how it arrived.
1219 /// Published because the route is the whole of decision 4 and a verifier
1220 /// has to be able to drive both routes through the SAME door the scanner
1221 /// and the hash handler use — a second door would be a second place for
1222 /// the rule to be got wrong.
1223 offer: accept,
1224 showSafety: showSafety,
1225 /// Draw the list into a container, and redraw it. `#social-people-list`
1226 /// is where `start` puts it; this is published so a second surface can
1227 /// have the SAME rows rather than a second rendering of a key state.
1228 mount: mount,
1229 refresh: function () { projection = null; return redraw ? redraw() : Promise.resolve(0); },
1230 // The reader, brought up on demand. Published so the verifier drives the
1231 // same loader the scanner does rather than a second one of its own.
1232 scanner: scanner,
1233 };
1234
1235 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
1236 else start();
1237})();