provared 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +1062 -0
  2. package/LICENSE +202 -0
  3. package/LICENSE-CC-BY-4.0.txt +396 -0
  4. package/README.md +689 -0
  5. package/bin/provared-check.mjs +565 -0
  6. package/bin/provared-headers.mjs +110 -0
  7. package/docs/threat-model.md +997 -0
  8. package/net/headers.js +403 -0
  9. package/package.json +57 -0
  10. package/page/check-page.js +162 -0
  11. package/page/index.html +47 -0
  12. package/page/render.js +530 -0
  13. package/page/style.css +150 -0
  14. package/samples/office-supplies.expected.json +82 -0
  15. package/samples/office-supplies.jsonl +10 -0
  16. package/samples/office-supplies.show.json +59 -0
  17. package/spec/provared-format.md +1672 -0
  18. package/src/actions.js +90 -0
  19. package/src/approval.js +73 -0
  20. package/src/blockstamp.js +198 -0
  21. package/src/book.js +2232 -0
  22. package/src/check.d.ts +21 -0
  23. package/src/check.js +15 -0
  24. package/src/compare.js +276 -0
  25. package/src/cover.js +198 -0
  26. package/src/der.js +192 -0
  27. package/src/encoding.js +286 -0
  28. package/src/fields.js +94 -0
  29. package/src/guard.js +171 -0
  30. package/src/headers.js +380 -0
  31. package/src/index.d.ts +2 -0
  32. package/src/index.js +37 -0
  33. package/src/jws.js +235 -0
  34. package/src/keys.js +149 -0
  35. package/src/pass.js +67 -0
  36. package/src/passkey-browser.d.ts +20 -0
  37. package/src/passkey-browser.js +171 -0
  38. package/src/recorder.js +1115 -0
  39. package/src/seal.js +133 -0
  40. package/src/service.js +124 -0
  41. package/src/signatures.js +147 -0
  42. package/src/slip.js +401 -0
  43. package/src/standing.js +181 -0
  44. package/src/stub.js +140 -0
  45. package/src/timestamp.js +351 -0
  46. package/src/tools.js +192 -0
  47. package/src/tree.js +143 -0
  48. package/src/types.d.ts +628 -0
  49. package/src/webauthn.js +119 -0
package/page/render.js ADDED
@@ -0,0 +1,530 @@
1
+ // Writes the result of a check into the page.
2
+ //
3
+ // Every value that comes from a record is untrusted. It is written with
4
+ // textContent, as text, and never as markup. Control characters, the
5
+ // characters that change the direction of text, and long runs of marks
6
+ // drawn on top of a letter are replaced by a visible mark, and each value
7
+ // is kept inside its own box, so that a record cannot hide, reorder or
8
+ // draw over the words around it.
9
+
10
+ import { REFUSAL_REASONS, conditionWords, limitWords, neverWords } from '../src/check.js';
11
+
12
+ // The same holds for characters that cannot be seen (zero-width characters,
13
+ // soft hyphens, line separators, fillers), with which two different labels
14
+ // could be made to look the same. A tab and a line feed are left as they are.
15
+ // \p{DI} is every character that Unicode says is to be left out when text
16
+ // is drawn (variation selectors among them); U+2800 is the blank Braille
17
+ // pattern, which looks like a space and is not one.
18
+ const UNSAFE = /(?![\t\n])[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cs}\p{DI}\u2800]/gu;
19
+
20
+ // A name or a purpose from a record is shown inside quotation marks, and
21
+ // any quotation mark inside it as an apostrophe, so that a reader can see
22
+ // where the record's words end and the checker's begin.
23
+ const QUOTES = /["\p{Pi}\p{Pf}\uff02\u2033\u2036\u02ba\u02dd\u02ee\u301d\u301e\u301f\u275d\u275e\u2e42]/gu;
24
+
25
+ /**
26
+ * @param {unknown} text a name or a purpose from a record
27
+ * @returns {string} the text in quotation marks
28
+ */
29
+ export function label(text) {
30
+ return `"${String(text).replace(QUOTES, "'")}"`;
31
+ }
32
+
33
+ /**
34
+ * Read a box of fingerprints, one to a line. A line that is not a
35
+ * fingerprint is never dropped silently: dropping it would switch off the
36
+ * very check the person asked for.
37
+ * @param {string} text
38
+ * @returns {{values: string[], bad: string[]}}
39
+ */
40
+ export function readFingerprints(text) {
41
+ const lines = String(text).split(/\s+/).filter((line) => line !== '');
42
+ return { values: lines.filter((line) => /^[A-Za-z0-9_-]{43}$/.test(line)), bad: lines.filter((line) => !/^[A-Za-z0-9_-]{43}$/.test(line)) };
43
+ }
44
+
45
+ /**
46
+ * Read the box of blocks, one fingerprint of a block to a line: 64 hex
47
+ * characters each. As with the other boxes, a line that is not one is
48
+ * never dropped silently.
49
+ * @param {string} text
50
+ * @returns {{values: string[], bad: string[]}}
51
+ */
52
+ export function readBlocks(text) {
53
+ const lines = String(text).split(/\s+/).filter((line) => line !== '');
54
+ return { values: lines.filter((line) => /^[0-9a-fA-F]{64}$/.test(line)), bad: lines.filter((line) => !/^[0-9a-fA-F]{64}$/.test(line)) };
55
+ }
56
+
57
+ /**
58
+ * @param {unknown} text
59
+ * @returns {string} the text, safe to place among other words
60
+ */
61
+ export function shown(text) {
62
+ // Every run of spaces becomes one space, so that a label padded with
63
+ // spaces cannot push words onto a line of their own. Marks drawn on top
64
+ // of the character before them (accents, for one) are kept to three in a
65
+ // row, and the rest of such a run is shown as one mark: a long run is
66
+ // drawn over the lines above and below.
67
+ return String(text).replace(UNSAFE, '\ufffd').replace(/(?<=\p{M}{3})\p{M}+/gu, '\ufffd').replace(/\p{Zs}+/gu, ' ');
68
+ }
69
+
70
+ /**
71
+ * @param {string} tag
72
+ * @param {string} [className]
73
+ * @param {string} [text]
74
+ * @returns {HTMLElement}
75
+ */
76
+ export function el(tag, className, text) {
77
+ const e = document.createElement(tag);
78
+ if (className) e.className = className;
79
+ if (text !== undefined) e.textContent = shown(text);
80
+ return e;
81
+ }
82
+
83
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
84
+
85
+ /**
86
+ * A time from a record, in words. The checker has already confirmed its
87
+ * form; anything else is shown as it is.
88
+ * @param {string} time such as 2026-10-05T09:00:00Z
89
+ * @returns {string} such as 5 October 2026, 09:00:00 UTC
90
+ */
91
+ export function showTime(time) {
92
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})Z$/.exec(time);
93
+ if (!m || !MONTHS[Number(m[2]) - 1]) return String(time);
94
+ return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}, ${m[4]} UTC`;
95
+ }
96
+
97
+ const STATE_WORDS = { valid: 'valid', invalid: 'INVALID', unavailable: 'not checked on this device', unchecked: 'not checked' };
98
+
99
+ function row(list, name, value, mono) {
100
+ list.append(el('dt', '', name), el('dd', mono ? 'mono' : '', value));
101
+ }
102
+
103
+ function flag(card, kind, text) {
104
+ card.append(el('p', `flag ${kind}`, text));
105
+ }
106
+
107
+ function answer(list, kind, title, detail) {
108
+ const item = el('li', kind);
109
+ item.append(el('strong', '', title), el('span', '', detail));
110
+ list.append(item);
111
+ }
112
+
113
+ function signatureWords(signatures) {
114
+ return signatures.map((s) => `${s.method}: ${STATE_WORDS[s.state]}`).join('; ');
115
+ }
116
+
117
+ // What an entry says is shown only if the entry passed its check and this
118
+ // device could confirm at least one of its signatures. Otherwise it is not
119
+ // evidence, and its claims are not put on the page.
120
+ function hidden(e) {
121
+ if (e.problems.length) return 'What this entry says is not shown, because the entry did not pass its check.';
122
+ if (e.verified === 'none') return 'What this entry says is not shown, because this device could not check its signature.';
123
+ return null;
124
+ }
125
+
126
+ // What stands behind a name: an organisation the reader trusts, an
127
+ // organisation the reader did not name, or nothing.
128
+ const COVERED = '(covered: not shown to you)';
129
+ // A name or a purpose is read only where the record itself holds it.
130
+ const has = (object, name) => Object.hasOwn(object, name);
131
+ // Who vouches for a service: only what the checker put there for that id.
132
+ const vouchedService = (e, id) => (Object.hasOwn(e.vouched.services, id) ? e.vouched.services[id] : null);
133
+
134
+ function standing(v) {
135
+ if (v && v.counted) return `vouched for by ${label(v.by)}, key set ${v.keys}`;
136
+ if (v) return `a label; it is not checked. ${label(v.by)} vouches for it, which you did not name as trusted`;
137
+ return 'a label; it is not checked';
138
+ }
139
+
140
+ function stampRows(list, stamps) {
141
+ for (const s of stamps) {
142
+ if (s.kind === 'block') {
143
+ const words = `in block ${s.authority} (number ${s.height}, as the proof states). The block states the time ${showTime(s.when)}; that is taken as right to within two hours`;
144
+ if (s.state === 'valid') row(list, 'Block time-stamp', `${words}. You named this block as trusted`);
145
+ else if (s.state === 'untrusted') row(list, 'Block time-stamp, not counted', `${words}. You did not name this block as trusted`);
146
+ } else if (s.state === 'valid') row(list, 'Outside time-stamp', `${showTime(s.when)}, from a service you named as trusted (certificate ${s.authority})`);
147
+ else if (s.state === 'untrusted') row(list, 'Time-stamp, not counted', `${showTime(s.when)}, from a service you did not name as trusted (certificate ${s.authority})`);
148
+ else if (s.state === 'unavailable') row(list, 'Time-stamp, not checked', 'this browser cannot check the service\'s signing method');
149
+ }
150
+ }
151
+
152
+ function cancellationCard(e) {
153
+ const card = el('div', `card slip${e.problems.length ? ' broken' : ''}`);
154
+ card.append(el('h3', 'kind', `Entry ${e.index}: Cancellation (the person ends a slip)`));
155
+ if (hidden(e)) {
156
+ card.append(el('p', 'quiet', hidden(e)));
157
+ return card;
158
+ }
159
+ const list = el('dl');
160
+ row(list, 'Cancels the slip', e.content.slip, true);
161
+ row(list, 'Dated', `${showTime(e.content.when)} (the person's device's own word)`);
162
+ stampRows(list, e.stamps);
163
+ if (e.stampedAt) row(list, 'Counts as cancelled at', `${showTime(e.stampedAt)} (the earliest its time-stamps allow)`);
164
+ row(list, 'Signature', `${e.signature.method}: ${STATE_WORDS[e.signature.state]}`);
165
+ row(list, 'Fingerprint', e.fingerprint, true);
166
+ card.append(list);
167
+ return card;
168
+ }
169
+
170
+ // An acknowledgement: the agent's side says it was handed a cancellation.
171
+ function acknowledgementCard(e) {
172
+ const card = el('div', `card stub${e.problems.length ? ' broken' : ''}`);
173
+ card.append(el('h3', 'kind', `Entry ${e.index}: Acknowledgement (the agent's side says it was handed a cancellation)`));
174
+ if (hidden(e)) {
175
+ card.append(el('p', 'quiet', hidden(e)));
176
+ return card;
177
+ }
178
+ const c = e.content;
179
+ const list = el('dl');
180
+ row(list, 'From', c.pass ? `the helper agent of pass ${c.pass}` : 'the slip\'s own agent');
181
+ row(list, 'Of the slip', c.slip, true);
182
+ row(list, 'The cancellation', e.cancellation === undefined ? c.cancellation : `${c.cancellation}, at entry ${e.cancellation}`, true);
183
+ row(list, 'Handed over at', `${showTime(c.when)} (the agent's side's own word)`);
184
+ row(list, 'Agent\'s signatures', signatureWords(e.signatures));
185
+ row(list, 'Fingerprint', e.fingerprint, true);
186
+ card.append(list);
187
+ card.append(el('p', 'quiet', 'This shows what the agent\'s side said, not when the person cancelled.'));
188
+ return card;
189
+ }
190
+
191
+ // A cancellation the person kept, handed over beside the book.
192
+ function heldCard(h, n) {
193
+ const card = el('div', `card slip${h.problems.length ? ' broken' : ''}`);
194
+ card.append(el('h3', 'kind', `Handed over beside the book, ${n}: the person's own copy of a cancellation`));
195
+ if (h.problems.length) {
196
+ card.append(el('p', 'quiet', 'It did not pass its check, so it was not used. See the problems above.'));
197
+ return card;
198
+ }
199
+ if (h.signature.state === 'valid') {
200
+ const list = el('dl');
201
+ row(list, 'Cancels the slip', h.slip, true);
202
+ row(list, 'Dated', `${showTime(h.content.when)} (the person's device's own word)`);
203
+ stampRows(list, h.stamps);
204
+ if (h.stampedAt) row(list, 'Counts as cancelled at', `${showTime(h.stampedAt)} (the earliest its time-stamps allow)`);
205
+ row(list, 'In the book', h.inBook === null ? 'no' : `yes, at entry ${h.inBook}`);
206
+ for (const a of h.acknowledgements) {
207
+ const who = a.by === 'helper' ? `the helper agent of pass ${a.pass}` : 'the slip\'s own agent';
208
+ if (a.state === 'valid') row(list, 'Acknowledged', `by ${who}, at ${showTime(a.when)} (the agent's side's own word)${a.inBook === null ? '' : `. The book holds the acknowledgement at entry ${a.inBook}`}`);
209
+ else row(list, 'Acknowledgement, not checked', `from ${who}: ${signatureWords(a.signatures)}`);
210
+ }
211
+ row(list, 'Signature', `${h.signature.method}: ${STATE_WORDS[h.signature.state]}`);
212
+ row(list, 'Fingerprint', h.fingerprint, true);
213
+ card.append(list);
214
+ } else {
215
+ card.append(el('p', 'quiet', 'What this copy says is not shown, because this device could not check its signature.'));
216
+ }
217
+ for (const n of h.notes) flag(card, 'note', n);
218
+ return card;
219
+ }
220
+
221
+ function passCard(e) {
222
+ const card = el('div', `card slip${e.problems.length ? ' broken' : ''}`);
223
+ card.append(el('h3', 'kind', `Entry ${e.index}: Pass (a permission handed on to a helper agent)`));
224
+ if (hidden(e)) {
225
+ card.append(el('p', 'quiet', hidden(e)));
226
+ return card;
227
+ }
228
+ const c = e.content;
229
+ const list = el('dl');
230
+ row(list, 'Under the slip', c.slip, true);
231
+ row(list, 'Handed on by', c.from ? `the helper agent of pass ${c.from}` : 'the slip\'s own agent');
232
+ row(list, 'To the helper agent', `${label(c.to.name)} (${standing(e.vouched)})`);
233
+ row(list, 'May do', c.actions.join(', '));
234
+ for (const l of c.limits) row(list, 'Limit', limitWords(l));
235
+ row(list, 'From', showTime(c.validFrom));
236
+ row(list, 'Until', showTime(c.validUntil));
237
+ row(list, 'Signatures', signatureWords(e.signatures));
238
+ row(list, 'Fingerprint', e.fingerprint, true);
239
+ card.append(list);
240
+ return card;
241
+ }
242
+
243
+ function vouchingCard(e) {
244
+ const card = el('div', `card show${e.problems.length ? ' broken' : ''}`);
245
+ card.append(el('h3', 'kind', `Entry ${e.index}: Vouching record (an organisation stands behind a name)`));
246
+ if (hidden(e)) {
247
+ card.append(el('p', 'quiet', hidden(e)));
248
+ return card;
249
+ }
250
+ const c = e.content;
251
+ const list = el('dl');
252
+ row(list, 'Organisation', `${label(c.by.name)} (a label; it is not checked)`);
253
+ row(list, 'Its key set', e.signer, true);
254
+ row(list, 'Vouches for', `the ${c.for.kind} named ${label(c.for.name)}, as the holder of the key or keys in this record`);
255
+ row(list, 'In force from', showTime(c.validFrom));
256
+ row(list, 'In force until', showTime(c.validUntil));
257
+ row(list, 'Counted', e.counted ? 'yes: you named this organisation as trusted' : 'no: you did not name this organisation as trusted');
258
+ row(list, 'Organisation\'s signatures', signatureWords(e.signatures));
259
+ row(list, 'Fingerprint', e.fingerprint, true);
260
+ card.append(list);
261
+ return card;
262
+ }
263
+
264
+ function withdrawalCard(e) {
265
+ const card = el('div', `card show${e.problems.length ? ' broken' : ''}`);
266
+ card.append(el('h3', 'kind', `Entry ${e.index}: Withdrawal (an organisation ends a vouching record)`));
267
+ if (hidden(e)) {
268
+ card.append(el('p', 'quiet', hidden(e)));
269
+ return card;
270
+ }
271
+ const list = el('dl');
272
+ row(list, 'Withdraws', `the vouching record ${e.content.vouching}`, true);
273
+ row(list, 'By the key set', e.signer, true);
274
+ row(list, 'Dated', `${showTime(e.content.when)} (the organisation's own word)`);
275
+ row(list, 'Organisation\'s signatures', signatureWords(e.signatures));
276
+ card.append(list);
277
+ return card;
278
+ }
279
+
280
+ function slipCard(e) {
281
+ const card = el('div', `card slip${e.problems.length ? ' broken' : ''}`);
282
+ card.append(el('h3', 'kind', `Entry ${e.index}: Slip (the permission)`));
283
+ if (hidden(e)) {
284
+ card.append(el('p', 'quiet', hidden(e)));
285
+ return card;
286
+ }
287
+ const c = e.content;
288
+ const list = el('dl');
289
+ row(list, 'Issuer', has(c.issuer, 'name') ? `${label(c.issuer.name)} (${standing(e.vouched.issuer)})` : COVERED);
290
+ row(list, 'Issuer\'s key', `${e.issuerKey}. Compare this thumbprint with the one the issuer gave you.`, true);
291
+ row(list, 'Signed on', c.issuer.origin);
292
+ row(list, 'Agent', has(c.agent, 'name') ? `${label(c.agent.name)} (${standing(e.vouched.agent)})` : COVERED);
293
+ for (const s of has(c.agent, 'software') ? c.agent.software : []) row(list, 'Agent\'s software, as stated', `${label(s.name)} (fingerprint ${s.sha256}). A statement, not proof of what ran.`);
294
+ row(list, 'May do', c.actions.join(', '));
295
+ for (const l of c.limits) row(list, 'Limit', limitWords(l));
296
+ for (const r of c.requires) row(list, 'Condition', conditionWords(r));
297
+ for (const n of c.never) row(list, 'Stated rule', neverWords(n));
298
+ if (c.with.length === 0) row(list, 'With', 'Nobody is named');
299
+ for (const s of c.with) row(list, 'With', has(s, 'name') ? `${label(s.name)} [${s.id}] (${standing(vouchedService(e, s.id))})` : `${COVERED} [${s.id}]`);
300
+ if (has(c, 'passes')) row(list, 'May be passed on', c.passes === 1 ? 'once, to a helper agent' : `${c.passes} times in a row, to helper agents`);
301
+ row(list, 'From', showTime(c.validFrom));
302
+ row(list, 'Until', showTime(c.validUntil));
303
+ row(list, 'Purpose', has(c, 'purpose') ? label(c.purpose) : COVERED);
304
+ if (e.covered.length) row(list, 'Covered fields', `${e.covered.length}. They are part of what was signed, and are not shown to you.`);
305
+ row(list, 'Signature', `${e.signature.method}: ${STATE_WORDS[e.signature.state]}`);
306
+ row(list, 'Fingerprint', e.fingerprint, true);
307
+ card.append(list);
308
+ return card;
309
+ }
310
+
311
+ function stubCard(e) {
312
+ const card = el('div', `card stub${e.problems.length ? ' broken' : ''}`);
313
+ card.append(el('h3', 'kind', `Entry ${e.index}: Stub (a receipt)`));
314
+ if (hidden(e)) {
315
+ card.append(el('p', 'quiet', hidden(e)));
316
+ return card;
317
+ }
318
+ const c = e.content;
319
+ const list = el('dl');
320
+ row(list, 'Number', c.pass ? `${c.seq} under a pass, by a helper agent` : `${c.seq} under its slip`);
321
+ if (c.pass) row(list, 'Under the pass', c.pass, true);
322
+ row(list, 'When', showTime(c.when));
323
+ row(list, 'Action', c.action);
324
+ if (c.amount) row(list, 'Amount', `${c.amount.value} ${c.amount.unit}`);
325
+ if (c.with) row(list, 'With', `[${c.with}]`);
326
+ for (const d of c.details ?? []) row(list, 'Document', `${label(d.name)} (fingerprint ${d.sha256})`);
327
+ if (c.terms) row(list, 'Relies on', `the service's terms for agents, fingerprint ${c.terms}`, true);
328
+ row(list, 'Agent\'s signatures', signatureWords(e.signatures));
329
+ if (e.countersignature.state !== 'absent') row(list, 'Countersignature', signatureWords(e.countersignature.signatures));
330
+ if (e.approval.state !== 'absent') row(list, 'Approved by the person', `with the passkey the slip names: ${STATE_WORDS[e.approval.state]}. Dated ${showTime(e.approval.when)}, the person's device's own word.`);
331
+ if (e.running) row(list, 'Running total', `${e.running.total} of ${e.running.max} ${e.running.unit}`);
332
+ row(list, 'Fingerprint', e.fingerprint, true);
333
+ card.append(list);
334
+ if (e.countersignature.state === 'absent') {
335
+ flag(
336
+ card,
337
+ 'onesided',
338
+ c.with
339
+ ? 'One-sided: there is no countersignature. This shows what the agent\'s side said, not what the other side did.'
340
+ : 'No other party is named. This shows what the agent\'s side said.',
341
+ );
342
+ }
343
+ return card;
344
+ }
345
+
346
+ function refusalCard(e) {
347
+ const card = el('div', `card refusal${e.problems.length ? ' broken' : ''}`);
348
+ card.append(el('h3', 'kind', `Entry ${e.index}: Refusal (what a service says it refused)`));
349
+ if (hidden(e)) {
350
+ card.append(el('p', 'quiet', hidden(e)));
351
+ return card;
352
+ }
353
+ const c = e.content;
354
+ const list = el('dl');
355
+ row(list, 'When', showTime(c.when));
356
+ row(list, 'Refused', c.action + (c.amount ? `, ${c.amount.value} ${c.amount.unit}` : ''));
357
+ row(list, 'Reason given', REFUSAL_REASONS[c.reason]);
358
+ row(list, 'Signed by', e.service ? `the service the slip names as [${e.service}]` : 'a party the slip does not name');
359
+ row(list, 'Calls itself', `${label(c.by.name)} (a label; it is not checked)`);
360
+ row(list, 'Signed with the key set', e.signer, true);
361
+ row(list, 'Service\'s signatures', signatureWords(e.signatures));
362
+ row(list, 'Fingerprint', e.fingerprint, true);
363
+ card.append(list);
364
+ flag(card, 'onesided', 'This shows what the service\'s side said, not what the agent did.');
365
+ return card;
366
+ }
367
+
368
+ function sealCard(e) {
369
+ const card = el('div', `card seal${e.problems.length ? ' broken' : ''}`);
370
+ card.append(el('h3', 'kind', `Entry ${e.index}: Seal (the book, signed by whoever keeps it)`));
371
+ if (hidden(e)) {
372
+ card.append(el('p', 'quiet', hidden(e)));
373
+ return card;
374
+ }
375
+ const c = e.content;
376
+ const list = el('dl');
377
+ row(list, 'Covers', `the first ${c.size} entries`);
378
+ row(list, 'Top fingerprint', c.root, true);
379
+ row(list, 'Sealed at', `${showTime(c.when)} (the recorder's own word)`);
380
+ row(list, 'Recorder', `${label(c.by.name)} (${standing(e.vouched)})`);
381
+ row(list, 'Recorder\'s key set', `${e.sealer}. Compare this fingerprint with the one the recorder gave you.`, true);
382
+ row(list, 'Recorder\'s signatures', signatureWords(e.signatures));
383
+ stampRows(list, e.stamps);
384
+ row(list, 'Fingerprint', e.fingerprint, true);
385
+ card.append(list);
386
+ if (e.stamps.length === 0) flag(card, 'onesided', 'There is no outside time-stamp: the time is only the recorder\'s own word.');
387
+ return card;
388
+ }
389
+
390
+ function termsCard(e) {
391
+ const card = el('div', `card terms${e.problems.length ? ' broken' : ''}`);
392
+ card.append(el('h3', 'kind', `Entry ${e.index}: A service's terms for agents`));
393
+ if (hidden(e)) {
394
+ card.append(el('p', 'quiet', hidden(e)));
395
+ return card;
396
+ }
397
+ const c = e.content;
398
+ const list = el('dl');
399
+ row(list, 'From', `${label(c.by.name)} (a label; it is not checked)`);
400
+ row(list, 'Signed with the key set', `${e.signer}. These terms count for a stub only if its slip gives these keys for the service.`, true);
401
+ row(list, 'Accepts from agents', c.accepts.join(', '));
402
+ for (const n of c.never) row(list, 'Asks of agents', neverWords(n));
403
+ row(list, 'In force from', showTime(c.validFrom));
404
+ row(list, 'In force until', showTime(c.validUntil));
405
+ row(list, 'Service\'s signatures', signatureWords(e.signatures));
406
+ row(list, 'Fingerprint', e.fingerprint, true);
407
+ card.append(list);
408
+ return card;
409
+ }
410
+
411
+ /**
412
+ * @param {HTMLElement} target where to write
413
+ * @param {object} result what checkBook or checkShow returned
414
+ * @param {object} [o]
415
+ * @param {boolean} [o.isShow]
416
+ * @param {string} [o.checkedBy] a sentence saying which program made the check
417
+ * @param {object} [o.options] what the check was told to trust: the green answer needs the passkey the
418
+ * person trusts and, where the record has a seal, the recorder they expect
419
+ */
420
+ export function renderResult(target, result, { isShow = false, checkedBy = 'Checked in this browser.', options = {} } = {}) {
421
+ target.replaceChildren();
422
+ const s = result.summary;
423
+ const missing = s.methodsMissing.join(' or ');
424
+ // Without the keys the person named, a record agrees only with the keys
425
+ // inside it, and anyone could have made those.
426
+ const issuerNamed = Array.isArray(options.issuerKeys) && options.issuerKeys.length > 0;
427
+ const named = issuerNamed && (s.counts.seals === 0 || (Array.isArray(options.sealKeys) && options.sealKeys.length > 0));
428
+ const sure = s.intact && named;
429
+
430
+ target.append(el('h2', '', 'The answer'));
431
+ const answers = el('ul', 'answers');
432
+ if (s.problemFound) {
433
+ answer(answers, 'no', 'The record is NOT intact.', 'See the problems marked below. A record that is not intact is not evidence.');
434
+ } else if (sure) {
435
+ answer(answers, 'yes', 'The record is intact.', `Every signature fits its key, every label is right and nothing has been changed. ${checkedBy}`);
436
+ } else if (s.intact) {
437
+ answer(
438
+ answers,
439
+ 'partly',
440
+ 'Intact only as far as its own keys show.',
441
+ issuerNamed
442
+ ? 'The record has a seal, and you named no recorder you expect, so anyone could have sealed it. Put the fingerprint of the recorder you expect in its box. This is not a pass.'
443
+ : 'You named no passkey you trust, so anyone could have made a record like this. Put the thumbprint of the passkey you trust in its box. This is not a pass.',
444
+ );
445
+ } else {
446
+ answer(
447
+ answers,
448
+ 'partly',
449
+ 'No problem was found, but the check is not complete.',
450
+ `This browser has no built-in ${missing}. Signatures made that way were not checked, so this is not a pass. To check everything, use the command-line checker with Node.js.`,
451
+ );
452
+ }
453
+ if (!s.problemFound) {
454
+ const rests = !s.intact ? ' This rests only on the signatures that could be checked here.' : !named ? ' This rests on keys you did not name.' : '';
455
+ if (s.firstBreach !== null) {
456
+ answer(answers, 'no', 'The record shows the agent outside its slip.', `First at entry ${s.firstBreach}. See the entries marked below.${rests}`);
457
+ } else if (!s.withinSlips) {
458
+ answer(answers, 'partly', 'Whether the agent stayed within its slip could not be checked here.', 'An entry that this browser cannot confirm cannot be compared with its slip.');
459
+ } else if (isShow) {
460
+ answer(answers, sure ? 'yes' : 'partly', 'These pages show nothing outside the slip.', `Single pages cannot show whether a limit was kept. That needs every stub under the slip.${rests}`);
461
+ } else {
462
+ answer(answers, sure ? 'yes' : 'partly', 'The agent stayed within its slip.', `As far as this record shows.${rests}`);
463
+ }
464
+ }
465
+ target.append(answers);
466
+
467
+ for (const p of result.problems) {
468
+ const card = el('div', 'card broken');
469
+ flag(card, 'problem', `Problem: ${p.message} (${p.code})`);
470
+ target.append(card);
471
+ }
472
+ for (const n of result.notes ?? []) {
473
+ const card = el('div', 'card show');
474
+ flag(card, 'note', n);
475
+ target.append(card);
476
+ }
477
+
478
+ const held = result.held ?? [];
479
+ if (held.length) target.append(el('h2', '', 'Handed over beside the book'));
480
+ held.forEach((h, i) => target.append(heldCard(h, i + 1)));
481
+
482
+ if (result.root) {
483
+ const seal = el('div', 'card seal');
484
+ seal.append(el('h3', 'kind', 'The top fingerprint (the Seal)'));
485
+ const list = el('dl');
486
+ row(list, 'Top fingerprint', result.root, true);
487
+ row(list, 'Entries in the book', String(result.size));
488
+ seal.append(list);
489
+ seal.append(
490
+ el(
491
+ 'p',
492
+ 'quiet',
493
+ s.sealed
494
+ ? s.sealed.by === 'block'
495
+ ? `It stands for the whole book. A block of a public blockchain that you named covers the first ${s.sealed.entries} entries: they existed by ${showTime(s.sealed.when)}.`
496
+ : `It stands for the whole book. An outside time-stamp from a service you trust covers the first ${s.sealed.entries} entries: they existed by ${showTime(s.sealed.when)}.`
497
+ : 'It stands for the whole book. No outside time-stamp from a service you trust covers it: compare it, and the number of entries, with a copy you already trust.',
498
+ ),
499
+ );
500
+ target.append(seal);
501
+ }
502
+
503
+ if (result.entries.length) target.append(el('h2', '', isShow ? 'The pages shown' : 'The entries'));
504
+ for (const e of result.entries) {
505
+ let card;
506
+ if (e.kind === 'slip') card = slipCard(e);
507
+ else if (e.kind === 'stub') card = stubCard(e);
508
+ else if (e.kind === 'refusal') card = refusalCard(e);
509
+ else if (e.kind === 'terms') card = termsCard(e);
510
+ else if (e.kind === 'seal') card = sealCard(e);
511
+ else if (e.kind === 'cancellation') card = cancellationCard(e);
512
+ else if (e.kind === 'acknowledgement') card = acknowledgementCard(e);
513
+ else if (e.kind === 'pass') card = passCard(e);
514
+ else if (e.kind === 'vouching') card = vouchingCard(e);
515
+ else if (e.kind === 'withdrawal') card = withdrawalCard(e);
516
+ else {
517
+ card = el('div', 'card unreadable');
518
+ card.append(el('h3', 'kind', `Entry ${e.index}: could not be read`));
519
+ }
520
+ for (const p of e.problems) flag(card, 'problem', `Problem: ${p.message} (${p.code})`);
521
+ for (const b of e.breaches) flag(card, 'breach', `Outside the slip: ${b.message} (${b.code})`);
522
+ for (const n of e.notes ?? []) flag(card, 'note', n);
523
+ target.append(card);
524
+ }
525
+
526
+ target.append(el('h2', '', 'What this check does not show'));
527
+ const limits = el('ul');
528
+ for (const l of s.limits) limits.append(el('li', '', l));
529
+ target.append(limits);
530
+ }
package/page/style.css ADDED
@@ -0,0 +1,150 @@
1
+ /* One style sheet for the checking page and the demonstration.
2
+ The four parts keep their colours: blue for the Slip, green for the Stub,
3
+ orange for the Seal (the top fingerprint), violet for the Show. Colour is
4
+ never the only sign: every state is also written in words. */
5
+
6
+ :root {
7
+ --slip: #1d5fbf;
8
+ --stub: #1f7a3f;
9
+ --seal: #b85c00;
10
+ --show: #6b3fa0;
11
+ --bad: #b3261e;
12
+ --text: #1c1b1f;
13
+ --quiet: #5c5c66;
14
+ --line: #d0d0d7;
15
+ --page: #ffffff;
16
+ --card: #f6f6f8;
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --slip: #8ab4f8;
22
+ --stub: #81c995;
23
+ --seal: #fdba74;
24
+ --show: #c4a7f5;
25
+ --bad: #f28b82;
26
+ --text: #e6e1e5;
27
+ --quiet: #a8a8b3;
28
+ --line: #44444d;
29
+ --page: #121214;
30
+ --card: #1d1d21;
31
+ }
32
+ }
33
+
34
+ * { box-sizing: border-box; }
35
+
36
+ body {
37
+ margin: 0;
38
+ padding: 1rem;
39
+ background: var(--page);
40
+ color: var(--text);
41
+ font: 1rem/1.5 system-ui, sans-serif;
42
+ }
43
+
44
+ main { max-width: 46rem; margin: 0 auto; }
45
+
46
+ h1 { font-size: 1.5rem; margin: 0 0 0.25rem; }
47
+ h2 { font-size: 1.15rem; margin: 2rem 0 0.5rem; }
48
+ h3 { font-size: 1rem; margin: 0 0 0.25rem; }
49
+ p { margin: 0.5rem 0; }
50
+ /* A fingerprint is one long word: let it break, so nothing scrolls sideways. */
51
+ p, li, dd, td, th { overflow-wrap: anywhere; }
52
+ .motto { color: var(--quiet); font-style: italic; margin-top: 0; }
53
+ .quiet { color: var(--quiet); }
54
+
55
+ a { color: var(--slip); }
56
+
57
+ button, .file {
58
+ display: inline-block;
59
+ min-height: 2.75rem;
60
+ padding: 0.5rem 1rem;
61
+ margin: 0.25rem 0.5rem 0.25rem 0;
62
+ border: 1px solid var(--slip);
63
+ border-radius: 0.5rem;
64
+ background: var(--slip);
65
+ color: var(--page);
66
+ font: inherit;
67
+ cursor: pointer;
68
+ }
69
+ button.second, .file { background: transparent; color: var(--slip); }
70
+ button:disabled { opacity: 0.5; cursor: default; }
71
+ button:focus-visible, .file:focus-within { outline: 3px solid var(--seal); outline-offset: 2px; }
72
+ .file input { position: absolute; width: 1px; height: 1px; opacity: 0; }
73
+
74
+ .answers { list-style: none; padding: 0; margin: 1rem 0; }
75
+ .answers li {
76
+ padding: 0.6rem 0.75rem;
77
+ margin: 0.4rem 0;
78
+ border-radius: 0.5rem;
79
+ border: 1px solid var(--line);
80
+ border-left: 0.4rem solid var(--stub);
81
+ background: var(--card);
82
+ }
83
+ .answers li.no { border-left-color: var(--bad); }
84
+ .answers li.partly { border-left-color: var(--seal); }
85
+ .answers strong { display: block; }
86
+
87
+ .card {
88
+ padding: 0.75rem;
89
+ margin: 0.6rem 0;
90
+ border: 1px solid var(--line);
91
+ border-left: 0.4rem solid var(--line);
92
+ border-radius: 0.5rem;
93
+ background: var(--card);
94
+ overflow-wrap: anywhere;
95
+ }
96
+ .card.slip { border-left-color: var(--slip); }
97
+ .card.stub, .card.refusal, .card.terms { border-left-color: var(--stub); }
98
+ .card.seal { border-left-color: var(--seal); }
99
+ .card.show { border-left-color: var(--show); }
100
+ .card.unreadable, .card.broken { border-left-color: var(--bad); }
101
+
102
+ .kind { font-weight: 700; }
103
+ .slip .kind { color: var(--slip); }
104
+ .stub .kind { color: var(--stub); }
105
+ .seal .kind { color: var(--seal); }
106
+ .show .kind { color: var(--show); }
107
+
108
+ dl { display: grid; grid-template-columns: minmax(7rem, 11rem) 1fr; gap: 0.15rem 0.75rem; margin: 0.4rem 0 0; }
109
+ dt { color: var(--quiet); }
110
+ /* A value from a record stays inside its own box: nothing it holds is drawn over the words around it. */
111
+ dd { margin: 0; overflow: hidden; }
112
+ @media (max-width: 30rem) {
113
+ dl { grid-template-columns: 1fr; }
114
+ dd { margin-bottom: 0.3rem; }
115
+ }
116
+
117
+ .mono { font-family: ui-monospace, monospace; font-size: 0.85rem; }
118
+
119
+ .flag {
120
+ margin: 0.5rem 0 0;
121
+ padding: 0.4rem 0.6rem;
122
+ border-radius: 0.4rem;
123
+ border: 1px solid var(--line);
124
+ }
125
+ .flag.problem, .flag.breach { border-color: var(--bad); color: var(--bad); font-weight: 600; }
126
+ .flag.onesided, .flag.note { border-color: var(--seal); }
127
+
128
+ .steps { list-style: none; padding: 0; counter-reset: step; }
129
+ .steps > li {
130
+ counter-increment: step;
131
+ padding: 0.75rem;
132
+ margin: 0.75rem 0;
133
+ border: 1px solid var(--line);
134
+ border-radius: 0.5rem;
135
+ }
136
+ .steps > li > h2 { margin-top: 0; }
137
+ .steps > li > h2::before { content: counter(step) ". "; }
138
+ .steps > li.waiting { opacity: 0.55; }
139
+
140
+ table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
141
+ th, td { text-align: left; padding: 0.3rem 0.4rem; border-bottom: 1px solid var(--line); vertical-align: top; }
142
+ td.number, th.number { text-align: right; }
143
+
144
+ [hidden] { display: none !important; }
145
+
146
+ details.more { margin: 1rem 0; padding: 0.6rem 0.9rem; border: 1px solid var(--line); border-radius: 0.5rem; }
147
+ details.more summary { cursor: pointer; font-weight: 600; }
148
+ details.more label { display: block; margin-top: 0.7rem; }
149
+ details.more input[type="file"] { display: block; max-width: 100%; margin-top: 0.3rem; color: var(--text); }
150
+ details.more textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--text); background: var(--page); border: 1px solid var(--line); border-radius: 0.3rem; }