provared 0.2.0__py3-none-any.whl

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.
provared/book.py ADDED
@@ -0,0 +1,2320 @@
1
+ # The book (a list of entries, one to a line), the chain, what a stub shows
2
+ # about the agent, the top fingerprint and the Show.
3
+ # Format description, sections 7 to 11 and 17 to 19.
4
+ #
5
+ # The checker keeps three questions apart:
6
+ # - was a problem found in the record? (problems)
7
+ # - was every signature checked on this device? (fullyChecked)
8
+ # - did the agent stay in its slip? (breaches)
9
+ # A breach is not a fault in the record. It is what a sound record shows.
10
+ # "Intact" is said only when no problem was found and every signature was
11
+ # checked.
12
+
13
+ import copy
14
+ import re
15
+ from collections.abc import Mapping
16
+
17
+ from . import _js
18
+ from . import fields as f
19
+ from .approval import request_of, validate_approval_content
20
+ from .blockstamp import check_block_stamp
21
+ from .compare import compare_with_slip, new_tally, settle_periods
22
+ from .encoding import Refusal, canonical_json, format_time, from_base64url, nan_time, parse_canonical, problem_from, to_base64url, utf8
23
+ from .jws import parse_record, signing_input
24
+ from .pass_ import MAX_PASSES, validate_pass_content
25
+ from .seal import CLOCK_ALLOWANCE_MS, decode_stamps, key_set_fingerprint, validate_seal_content
26
+ from .service import validate_refusal_content, validate_terms_content
27
+ from .signatures import verify_signature
28
+ from .slip import _UNDEFINED, check_passkey_signature, check_slip
29
+ from .standing import validate_acknowledgement_content, validate_cancellation_content, validate_vouching_content, validate_withdrawal_content
30
+ from .stub import validate_countersignature_content, validate_stub_content
31
+ from .timestamp import check_stamp
32
+ from .tree import inclusion_path, tree_builder, tree_root, verify_inclusion
33
+
34
+ INF = float('inf')
35
+ NAN = float('nan')
36
+
37
+ MAX_ENTRIES = 100000
38
+ """The most entries a book may hold."""
39
+ MAX_LINE_BYTES = 131072
40
+ """The longest line of a book, in bytes."""
41
+ MAX_PAGES = 64
42
+ """The most pages a Show may hold."""
43
+ MAX_HELD = 16
44
+ """The most cancellations that may be handed over beside a book."""
45
+ MAX_ACKNOWLEDGEMENTS = 4
46
+ """The most acknowledgements that may be handed over with one cancellation."""
47
+
48
+ # What can be said about time, with and without an outside time-stamp.
49
+ _NOT_STAMPED = (
50
+ 'Nothing here is time-stamped by an outside service that you named as trusted. So it does not show when the record was made, '
51
+ 'and the latest stubs under a slip or a pass, or a slip or a pass with all its stubs, could have been removed.'
52
+ )
53
+
54
+
55
+ def _block_words(sealed):
56
+ return (
57
+ f"A block of a public blockchain, which you trust to be part of the chain, covers the first {sealed['entries']} entries: they existed by {sealed['when']}, "
58
+ 'taking the time the block states as right to within two hours. What came after is not time-stamped, and a later seal with everything after it could '
59
+ 'have been removed: compare the top fingerprint and the number of entries with a copy you trust. A block time-stamp rests on SHA-256 and on the block '
60
+ 'being part of the chain, not on a signature.'
61
+ )
62
+
63
+
64
+ def _stamped_words(sealed):
65
+ if sealed['by'] == 'block':
66
+ return _block_words(sealed)
67
+ return (
68
+ f"An outside time-stamp covers the first {sealed['entries']} entries: they existed by {sealed['when']}, as the time-stamp service states. What came "
69
+ 'after is not time-stamped, and a later seal with everything after it could have been removed: compare the top fingerprint and the number of entries '
70
+ "with a copy you trust. Time-stamp services sign with methods of today's kind, which are not quantum-safe."
71
+ )
72
+
73
+
74
+ LIMITS = [
75
+ 'It shows what was recorded, not what was left out. An agent that acts and writes no stub leaves no trace here.',
76
+ "A one-sided stub shows what the agent's side said, not what the other side did.",
77
+ "A refusal shows what the service's side said, not what the agent did.",
78
+ 'It does not show that an action was wise, lawful or wanted.',
79
+ 'A rule of conduct in a slip, such as "never claim to be a human being", is the person\'s signed instruction. No check can show that the agent kept it.',
80
+ 'It does not show who was holding the device when the passkey signed.',
81
+ 'A name in a record is a label. Only keys are checked.',
82
+ _NOT_STAMPED,
83
+ ]
84
+ """What no check of a draft version 0 record can show. Stated with every result."""
85
+
86
+
87
+ def entry_line(entry):
88
+ """Write one entry of a book as its line (without the line feed)."""
89
+ return canonical_json(entry)
90
+
91
+
92
+ def write_book(entries):
93
+ """Write a book from its entries."""
94
+ return ''.join(entry_line(e) + '\n' for e in entries)
95
+
96
+
97
+ def _split_lines(text):
98
+ """Split a book into its lines, stopping as soon as there are too many, so
99
+ that a flood of line feeds costs nothing. A missing line feed after the
100
+ last line is allowed."""
101
+ lines = []
102
+ at = 0
103
+ n = len(text)
104
+ while at < n:
105
+ if len(lines) >= MAX_ENTRIES:
106
+ raise Refusal('too-large', 'The book holds more than 100,000 entries.')
107
+ feed = text.find('\n', at)
108
+ end = n if feed == -1 else feed
109
+ lines.append(text[at:end])
110
+ at = end + 1
111
+ return lines
112
+
113
+
114
+ def _check_key_set_signatures(parsed, keys, without):
115
+ """Check the signatures of a record against a key set, in order."""
116
+ states = []
117
+ for i, s in enumerate(parsed.signatures):
118
+ state = verify_signature(keys[i], s['signature'], signing_input(s['protected_b64'], parsed.payload_b64), without)
119
+ states.append({'method': keys[i]['alg'], 'state': state})
120
+ return states
121
+
122
+
123
+ def _overall(states):
124
+ if any(s['state'] == 'invalid' for s in states):
125
+ return 'invalid'
126
+ if any(s['state'] == 'unavailable' for s in states):
127
+ return 'unavailable'
128
+ return 'valid'
129
+
130
+
131
+ def _verified_by(states):
132
+ """How much of an entry's own signing was confirmed on this device: "all",
133
+ "some" (one of two signatures; the other method is not built in) or "none"."""
134
+ if len(states) == 0 or any(s['state'] == 'invalid' for s in states):
135
+ return 'none'
136
+ valid = sum(1 for s in states if s['state'] == 'valid')
137
+ return 'all' if valid == len(states) else 'some' if valid > 0 else 'none'
138
+
139
+
140
+ class _Entry(dict):
141
+ """An entry of an answer. It also keeps, out of sight, the time of the first
142
+ counted time-stamp that covers it."""
143
+
144
+ __slots__ = ('stamped',)
145
+
146
+
147
+ def _new_entry(index, kind='unreadable'):
148
+ e = _Entry(index=index, kind=kind, fingerprint=None, verified='none', problems=[], breaches=[], notes=[])
149
+ e.stamped = None
150
+ return e
151
+
152
+
153
+ class _Ids(set):
154
+ """The unique numbers so far, and the ids of the approvals used so far:
155
+ one approval is one action."""
156
+
157
+ __slots__ = ('approvals',)
158
+
159
+
160
+ class _Vouchings(dict):
161
+ """The vouching records so far, by fingerprint; and by whom they vouch for
162
+ ("index"), so that looking one up does not mean reading them all."""
163
+
164
+ __slots__ = ('index',)
165
+
166
+
167
+ class _Reader:
168
+ """What a reader keeps while it reads the entries of a book, one line at a
169
+ time. Everything worked out from the lines so far is here, so that one
170
+ more line can be read without reading the others again.
171
+
172
+ In "whole" mode the lines are a whole book: the chain and the running
173
+ totals are checked. Otherwise they are single pages out of a book, and
174
+ only what one page can show is checked."""
175
+
176
+ def __init__(self, options, whole):
177
+ self.options = options
178
+ self.whole = whole
179
+ # Whether this reader was split off another one to try one more stub, and reads nothing else (_fork_reader).
180
+ self.forked = False
181
+ self.entries = []
182
+ self.slips = {}
183
+ self.terms = {}
184
+ self.ids = _Ids()
185
+ self.ids.approvals = set()
186
+ # The passes so far, by fingerprint: permissions handed on to helper agents.
187
+ self.passes = {}
188
+ self.vouchings = _Vouchings()
189
+ self.vouchings.index = {}
190
+ # The seals so far: the last one, how far an outside time-stamp reaches,
191
+ # and the tree as it grows, so that each seal can be compared with the
192
+ # entries before it.
193
+ self.seals = {'count': 0, 'last': None, 'lastWhen': -INF, 'lastStampTime': -INF, 'latest': -INF, 'next': 0, 'sealed': None}
194
+ self.tree = tree_builder() if whole else None
195
+ # Problems with what was handed over beside the record (disclosures), which are not faults of an entry.
196
+ self.handed_problems = []
197
+
198
+
199
+ def _without(options):
200
+ value = options.get('withoutMethods')
201
+ return value if isinstance(value, list) else []
202
+
203
+
204
+ def _read_line(reader, index, line, disclosures=_UNDEFINED):
205
+ """Read one more line: check it against everything read before it, and
206
+ keep what later lines will need."""
207
+ options = reader.options
208
+ whole = reader.whole
209
+ entries = reader.entries
210
+ seals = reader.seals
211
+ without = _without(options)
212
+ entry = _new_entry(index)
213
+ entries.append(entry)
214
+ try:
215
+ # A character is at least one byte, so the first test needs no encoding.
216
+ if _js.utf16_length(line) > MAX_LINE_BYTES or len(utf8(line)) > MAX_LINE_BYTES:
217
+ raise Refusal('too-large', 'The line is longer than 131,072 bytes.')
218
+ # Three common slips get words of their own; the codes are those any
219
+ # other such line gets.
220
+ if line == '':
221
+ raise Refusal('not-json', 'The line is empty. A book holds no empty lines, and ends with a single line feed.')
222
+ if line.startswith(''):
223
+ raise Refusal('not-json', 'The line starts with a byte-order mark, which a book may not hold. Save the file as UTF-8 without one.')
224
+ if line.endswith('\r'):
225
+ raise Refusal('line-not-canonical', 'The line ends with a carriage return. A line of a book ends with a line feed alone, with no carriage return before it.')
226
+ value = parse_canonical(line, 'line-not-canonical')
227
+ names = ','.join(_js.sort_strings(value.keys())) if isinstance(value, dict) else ''
228
+ # A reader split off to try one more stub reads nothing else: a stub
229
+ # is the one kind of entry that changes nothing read before it.
230
+ if reader.forked and names not in _STUB_ENTRIES:
231
+ raise RuntimeError('only a stub may be tried')
232
+ if names == 'slip':
233
+ entry['kind'] = 'slip'
234
+ check = check_slip(value.get('slip'), options, disclosures)
235
+ # (Disclosures on a page of a Show and disclosures handed over beside it are used together: see check_slip.)
236
+ for p in check['disclosureProblems']:
237
+ reader.handed_problems.append({
238
+ 'code': p['code'],
239
+ 'message': f"The disclosures handed over for the slip at entry {index} did not pass their check, so they were not used: the slip was checked with those fields covered. {p['message']}",
240
+ })
241
+ entry.update({
242
+ 'covered': check['covered'],
243
+ 'fingerprint': check['fingerprint'],
244
+ 'problems': check['problems'],
245
+ 'notes': check['notes'],
246
+ 'signature': check['signature'],
247
+ 'issuerKey': check['issuerKey'],
248
+ 'content': check['content'],
249
+ })
250
+ ids = reader.ids
251
+ if check['content']:
252
+ if check['content']['id'] in ids:
253
+ entry['problems'].append({'code': 'duplicate-id', 'message': 'Another record has the same unique number.'})
254
+ ids.add(check['content']['id'])
255
+ if check['fingerprint']:
256
+ if check['fingerprint'] in reader.slips:
257
+ entry['problems'].append({'code': 'duplicate-slip', 'message': 'This slip is already in the book.'})
258
+ else:
259
+ reader.slips[check['fingerprint']] = {
260
+ 'content': check['content'],
261
+ 'usable': len(entry['problems']) == 0,
262
+ 'verified': len(entry['problems']) == 0 and check['signature']['state'] == 'valid',
263
+ 'next': 0,
264
+ 'last': None,
265
+ 'lastWhen': -INF,
266
+ 'tally': new_tally(),
267
+ # The stubs compared so far, and the cancellation, if there is one.
268
+ 'stubs': [],
269
+ 'cancelled': None,
270
+ }
271
+ entry['verified'] = 'all' if check['signature']['state'] == 'valid' else 'none'
272
+ # Who vouches for the names in the slip, if anyone does.
273
+ if check['content']:
274
+ s = check['content']
275
+ # A vouching record must be in force for the whole of the slip's time.
276
+ span = (nan_time(s['validFrom']), nan_time(s['validUntil']))
277
+ vouchings = reader.vouchings
278
+ entry['vouched'] = {
279
+ 'issuer': _vouch_for(vouchings, 'person', s['issuer']['key'], s['issuer'].get('name'), span),
280
+ 'agent': _vouch_for(vouchings, 'agent', s['agent']['keys'], s['agent'].get('name'), span),
281
+ # Every service has a place here, so that looking one up by its id finds only what was put there.
282
+ 'services': _js.in_key_order({
283
+ w['id']: _vouch_for(vouchings, 'service', w['keys'], w.get('name'), span) if keys_of(w) else None for w in s['with']
284
+ }),
285
+ }
286
+ # A name that an organisation the checker trusts vouches for is no longer only a label.
287
+ if entry['vouched']['issuer'] and entry['vouched']['issuer']['counted']:
288
+ entry['notes'] = [n for n in entry['notes'] if not n.startswith("The issuer's key was not compared")]
289
+ elif names in _STUB_ENTRIES:
290
+ entry['kind'] = 'stub'
291
+ _check_stub_entry(entry, value, reader.slips, reader.terms, reader.passes, reader.ids, without, whole)
292
+ elif names == 'refusal':
293
+ entry['kind'] = 'refusal'
294
+ _check_refusal_entry(entry, value, reader.slips, reader.ids, without, whole)
295
+ elif names == 'terms':
296
+ entry['kind'] = 'terms'
297
+ _check_terms_entry(entry, value, reader.terms, reader.ids, without)
298
+ elif names in ('seal', 'seal,stamps'):
299
+ entry['kind'] = 'seal'
300
+ # The top fingerprint of the entries before it is worked out only once the seal has been read.
301
+ before = (lambda: {'size': index, 'root': to_base64url(reader.tree.root())}) if whole else None
302
+ _check_seal_entry(entry, value, seals, reader.ids, options, before, entries, not whole)
303
+ if entry.get('content'):
304
+ # In force at the time the seal states and, where a counted time-stamp says when the seal existed, then too.
305
+ times = [nan_time(entry['content']['when'])]
306
+ if entry.get('stampedAt'):
307
+ times.append(nan_time(entry['stampedAt']))
308
+ by = entry['content']['by']
309
+ entry['vouched'] = _vouch_for(reader.vouchings, 'recorder', by['keys'], by.get('name'), (_js_min(times), _js_max(times) + 1))
310
+ if entry['vouched'] and entry['vouched']['counted']:
311
+ entry['notes'] = [n for n in entry['notes'] if not n.startswith('The keys that signed this seal were not compared')]
312
+ elif names in ('cancellation', 'cancellation,stamps'):
313
+ entry['kind'] = 'cancellation'
314
+ _check_cancellation_entry(entry, value, reader.slips, reader.ids, options, whole)
315
+ elif names == 'acknowledgement':
316
+ entry['kind'] = 'acknowledgement'
317
+ _check_acknowledgement_entry(entry, value, reader.slips, reader.passes, reader.ids, entries, without, whole)
318
+ elif names == 'pass':
319
+ entry['kind'] = 'pass'
320
+ _check_pass_entry(entry, value, reader.slips, reader.passes, reader.ids, without, whole)
321
+ if entry.get('content'):
322
+ c = entry['content']
323
+ entry['vouched'] = _vouch_for(reader.vouchings, 'agent', c['to']['keys'], c['to'].get('name'), (nan_time(c['validFrom']), nan_time(c['validUntil'])))
324
+ elif names == 'vouching':
325
+ entry['kind'] = 'vouching'
326
+ _check_vouching_entry(entry, value, reader.vouchings, reader.ids, options)
327
+ elif names == 'withdrawal':
328
+ entry['kind'] = 'withdrawal'
329
+ _check_withdrawal_entry(entry, value, reader.vouchings, reader.ids, options, whole)
330
+ else:
331
+ raise Refusal('unknown-entry', 'The line is not one of the known kinds of entry.')
332
+ # Only a slip may hold covered fields: disclosures handed over with any other page belong to nothing.
333
+ if disclosures is not _UNDEFINED and entry['kind'] != 'slip':
334
+ entry['problems'].append({'code': 'cover-invalid', 'message': 'Disclosures were handed over with a page that is not a slip. Only a slip may hold covered fields.'})
335
+ except Exception as e:
336
+ entry['problems'].append(problem_from(e))
337
+ if entry['problems']:
338
+ entry['verified'] = 'none'
339
+ # The latest time that a sound entry so far states: a seal cannot be dated before it.
340
+ elif entry['verified'] != 'none' and _when_of(entry) > seals['latest']:
341
+ seals['latest'] = _when_of(entry)
342
+ if reader.tree is not None:
343
+ reader.tree.add(utf8(line))
344
+
345
+
346
+ def _js_min(values):
347
+ """Math.min: NaN if any value is NaN; Infinity for none."""
348
+ result = INF
349
+ for v in values:
350
+ if v != v:
351
+ return NAN
352
+ if v < result:
353
+ result = v
354
+ return result
355
+
356
+
357
+ def _js_max(values):
358
+ """Math.max: NaN if any value is NaN; -Infinity for none."""
359
+ result = -INF
360
+ for v in values:
361
+ if v != v:
362
+ return NAN
363
+ if v > result:
364
+ result = v
365
+ return result
366
+
367
+
368
+ def _settle_late(reader):
369
+ """The findings that can be made only once every stub so far has been read:
370
+ the limits "in any period" (a stub further on in the book, in another
371
+ chain, may be dated earlier), and what a cancellation handed over beside
372
+ the book shows (_check_held). They are worked out afresh for each answer
373
+ and kept apart from the entries, each list under its entry's own list of
374
+ findings, so that a book can be carried on after an answer was given."""
375
+ late = _js.IdentityMap()
376
+ # The slips first, then the passes from the last in the book to the
377
+ # first, which is from the pass nearest a stub upwards: a finding
378
+ # already given for a stub is not given again (compare.py).
379
+ for slip in reader.slips.values():
380
+ if slip['content']:
381
+ settle_periods(slip['content'], slip['tally'], late)
382
+ for p in reversed(list(reader.passes.values())):
383
+ settle_periods(pass_as_slip(p['content']), p['tally'], late)
384
+ return late
385
+
386
+
387
+ def _answered(entry, late, entries):
388
+ """An entry as an answer gives it: with the findings that were worked out
389
+ last, and with no time of its own if it did not pass its check. The
390
+ entry the reader keeps is left as it is."""
391
+ more = late.get(entry['breaches'])
392
+ out = dict(entry)
393
+ if more:
394
+ out['breaches'] = [*entry['breaches'], *more]
395
+ # A cancellation that passed when its acknowledgement was read may have
396
+ # failed since: a later seal's time-stamp can show that it is not dated
397
+ # as it says. The acknowledgement then names a cancellation that did not
398
+ # pass its check.
399
+ if entry['kind'] == 'acknowledgement' and 'cancellation' in entry and entries[entry['cancellation']]['problems']:
400
+ _fail_acknowledgement(out)
401
+ if out['problems']:
402
+ out.pop('stampedAt', None)
403
+ return out
404
+
405
+
406
+ def _fail_acknowledgement(entry):
407
+ entry['problems'] = [*entry['problems'], {'code': 'cancellation-not-found', 'message': 'The cancellation this acknowledgement names did not pass its own check.'}]
408
+ entry['verified'] = 'none'
409
+ entry.pop('cancellation', None)
410
+
411
+
412
+ def _fork_reader(reader, whole=False):
413
+ """A reader that carries on by itself from where this one stands, to try
414
+ one more line. The reader it was split from is left exactly as it was,
415
+ whatever becomes of this one.
416
+
417
+ To try a stub, what a stub changes is copied (its chain, the running
418
+ totals, the unique numbers, the tree); the entries read so far, the
419
+ terms and the vouching records are shared, because a stub changes none
420
+ of them, and such a reader refuses any other kind of line.
421
+
422
+ To try a line of any kind ("whole"), the entries are copied as well,
423
+ with the lists inside them that a later line may add to (a seal's
424
+ time-stamp marks the entries before it; a cancellation marks stubs),
425
+ and so are the terms and the vouching records. Whatever points at an
426
+ entry, or at its list of findings, is pointed at the copy."""
427
+ twin_entry = _js.IdentityMap()
428
+ twin_list = _js.IdentityMap()
429
+ if whole:
430
+ entries = []
431
+ for e in reader.entries:
432
+ c = _Entry(e)
433
+ c['problems'] = list(e['problems'])
434
+ c['breaches'] = list(e['breaches'])
435
+ c['notes'] = list(e['notes'])
436
+ c.stamped = getattr(e, 'stamped', None)
437
+ twin_entry.set(e, c)
438
+ twin_list.set(e['breaches'], c['breaches'])
439
+ entries.append(c)
440
+ else:
441
+ entries = list(reader.entries)
442
+
443
+ def copy_tally(t):
444
+ periods = {}
445
+ for i, items in t['periods'].items():
446
+ if whole:
447
+ periods[i] = [{**item, 'late': twin_list.get(item['late'], item['late'])} for item in items]
448
+ else:
449
+ periods[i] = list(items)
450
+ return {'sums': dict(t['sums']), 'counts': dict(t['counts']), 'periods': periods}
451
+
452
+ slips = {}
453
+ for fp, s in reader.slips.items():
454
+ slips[fp] = {
455
+ **s,
456
+ 'tally': copy_tally(s['tally']),
457
+ 'stubs': [twin_entry.get(e, e) for e in s['stubs']] if whole else list(s['stubs']),
458
+ 'cancelled': None if s['cancelled'] is None else dict(s['cancelled']),
459
+ }
460
+ # The vouching records can be withdrawn by a later line: they are copied, and found again by the copies.
461
+ terms, vouchings = reader.terms, reader.vouchings
462
+ if whole:
463
+ terms = dict(reader.terms)
464
+ twin_vouching = _js.IdentityMap()
465
+ vouchings = _Vouchings()
466
+ for fp, v in reader.vouchings.items():
467
+ c = dict(v)
468
+ twin_vouching.set(v, c)
469
+ vouchings[fp] = c
470
+ vouchings.index = {}
471
+ for key, found in reader.vouchings.index.items():
472
+ vouchings.index[key] = {
473
+ 'counted': [twin_vouching.get(v, v) for v in found['counted']],
474
+ 'other': [twin_vouching.get(v, v) for v in found['other']],
475
+ }
476
+ # A pass points to the pass it was handed on from, which is earlier in the book: the copies point to the copies.
477
+ passes = {}
478
+ twins = _js.IdentityMap()
479
+ for fp, p in reader.passes.items():
480
+ c = {**p, 'parent': twins.get(p['parent']) if p['parent'] else None, 'tally': copy_tally(p['tally'])}
481
+ twins.set(p, c)
482
+ passes[fp] = c
483
+ ids = _Ids(reader.ids)
484
+ ids.approvals = set(reader.ids.approvals)
485
+ out = _Reader.__new__(_Reader)
486
+ out.options = reader.options
487
+ out.whole = reader.whole
488
+ out.forked = not whole
489
+ out.entries = entries
490
+ out.slips = slips
491
+ out.terms = terms
492
+ out.passes = passes
493
+ out.ids = ids
494
+ out.vouchings = vouchings
495
+ out.seals = dict(reader.seals)
496
+ out.tree = reader.tree.fork()
497
+ out.handed_problems = list(reader.handed_problems)
498
+ return out
499
+
500
+
501
+ # --- the options a checker reads ---
502
+
503
+ OPTION_NAMES = ['issuerKeys', 'sealKeys', 'stampServices', 'blocks', 'vouchers', 'disclosures', 'cancellations', 'expectedRoot', 'expectedSize', 'withoutMethods']
504
+
505
+ # The same options, as a Python caller may name them.
506
+ _SNAKE = {
507
+ 'issuer_keys': 'issuerKeys',
508
+ 'seal_keys': 'sealKeys',
509
+ 'stamp_services': 'stampServices',
510
+ 'blocks': 'blocks',
511
+ 'vouchers': 'vouchers',
512
+ 'disclosures': 'disclosures',
513
+ 'cancellations': 'cancellations',
514
+ 'expected_root': 'expectedRoot',
515
+ 'expected_size': 'expectedSize',
516
+ 'without_methods': 'withoutMethods',
517
+ }
518
+
519
+
520
+ class _Fixed(dict):
521
+ """The options of a check, fixed. "unreadable" names any that nest too deeply to be read."""
522
+
523
+ def __init__(self, *args, **kwargs):
524
+ super().__init__(*args, **kwargs)
525
+ self.unreadable = []
526
+
527
+
528
+ class _Unreadable:
529
+ """What stands, in a fixed copy of the options, for a value more than 16
530
+ levels down. In the JavaScript library such a value cannot be read: a
531
+ check that reads it fails there, and the stub writer refuses it as not
532
+ plain data. Here it stands as a value of no kind a check accepts, so a
533
+ check that reads it refuses it there, and the stub writer refuses it as
534
+ anything that is not plain data. No option nests so deep."""
535
+
536
+ __slots__ = ()
537
+
538
+ def __repr__(self):
539
+ return '<a value more than 16 levels down, which cannot be read>'
540
+
541
+
542
+ _UNREADABLE = _Unreadable()
543
+
544
+
545
+ def _plain(value, memo=None, depth=0, deep=None):
546
+ """A deep copy of an option, with tuples as lists, as JSON would hold it.
547
+ A value met twice is copied once, so that the copy holds the same
548
+ sharing, and costs no more to make than what was handed over holds."""
549
+ if not isinstance(value, (dict, list, tuple)) and not isinstance(value, Mapping):
550
+ # Text, numbers, True, False and None need no copy. Anything else (an
551
+ # object of a class, a generator, a lock) is kept as it was handed over,
552
+ # as the JavaScript library keeps an object of a class: a check refuses
553
+ # or sets it aside where it reads it.
554
+ return value
555
+ if depth > 16:
556
+ if deep is not None:
557
+ deep.append(True)
558
+ return _UNREADABLE
559
+ if memo is None:
560
+ memo = {}
561
+ found = memo.get(id(value))
562
+ if found is not None:
563
+ return found[1]
564
+ if isinstance(value, (list, tuple)):
565
+ made = [_plain(v, memo, depth + 1, deep) for v in value]
566
+ else:
567
+ made = {k: _plain(v, memo, depth + 1, deep) for k, v in value.items()}
568
+ memo[id(value)] = (value, made)
569
+ return made
570
+
571
+
572
+ def fixed_options(options=None, **named):
573
+ """The options of a check, fixed: read once and copied, so that nothing done
574
+ afterwards to what was handed over changes a check that is carried on.
575
+ The options are named as in the JavaScript library ("issuerKeys"), or
576
+ as keyword arguments in Python's way ("issuer_keys"). A fixed copy handed
577
+ in again is used as it is. Not part of the public interface."""
578
+ if isinstance(options, _Fixed) and not named:
579
+ return options
580
+ fixed = _Fixed()
581
+ if isinstance(options, Mapping):
582
+ for name in OPTION_NAMES:
583
+ if name in options:
584
+ deep = []
585
+ fixed[name] = _plain(options[name], deep=deep)
586
+ if deep:
587
+ # No option nests so deep. The check that reads it fails.
588
+ fixed.unreadable.append(name)
589
+ for name, value in named.items():
590
+ if name not in _SNAKE:
591
+ raise TypeError(f'unknown option "{name}"')
592
+ if value is not None:
593
+ deep = []
594
+ fixed[_SNAKE[name]] = _plain(value, deep=deep)
595
+ if deep:
596
+ fixed.unreadable.append(_SNAKE[name])
597
+ return fixed
598
+
599
+
600
+ _MAX_UNCOUNTED = 8
601
+ """The most vouching records from organisations the checker did not name that are kept for one name and key."""
602
+
603
+
604
+ def keys_of(service):
605
+ """The keys a slip gives for a service, if it gives any."""
606
+ return service['keys'] if isinstance(service, dict) and 'keys' in service else None
607
+
608
+
609
+ def _vouched_key(kind, key, name):
610
+ """What a vouching record is found by: the kind, the key or keys, and the name, exactly."""
611
+ return canonical_json([kind, key, name])
612
+
613
+
614
+ def _vouch_for(vouchings, kind, key, name, span):
615
+ """Who vouches for a name: the vouching record earlier in the book, still
616
+ standing, that names exactly this key (or these keys) and exactly this
617
+ name, and was in force for the whole of the time given. One from an
618
+ organisation the checker named as trusted is preferred, and only that
619
+ one counts. A covered name is not text, and nobody vouches for it."""
620
+ if not isinstance(name, str):
621
+ return None
622
+ found = vouchings.index.get(_vouched_key(kind, key, name))
623
+ if not found:
624
+ return None
625
+
626
+ def standing(v):
627
+ return not v['withdrawn'] and span[0] >= nan_time(v['content']['validFrom']) and span[1] <= nan_time(v['content']['validUntil'])
628
+
629
+ v = next((v for v in found['counted'] if standing(v)), None) or next((v for v in found['other'] if standing(v)), None)
630
+ return {'counted': v['counted'], 'by': v['content']['by']['name'], 'keys': v['signer']} if v else None
631
+
632
+
633
+ def _find_pass(passes, fp, slip_fingerprint, whole):
634
+ """The pass a stub or a further pass relies on."""
635
+ p = passes.get(fp)
636
+ if not p or not p['usable']:
637
+ raise Refusal(
638
+ 'pass-missing',
639
+ 'The pass this entry relies on is not earlier in the book, or did not pass its own check.'
640
+ if whole
641
+ else 'The pass this entry relies on is not among the pages, or did not pass its own check.',
642
+ )
643
+ if p['content']['slip'] != slip_fingerprint:
644
+ raise Refusal('pass-mismatch', 'The pass this entry relies on was given under another slip.')
645
+ return p
646
+
647
+
648
+ def pass_as_slip(p):
649
+ """A pass seen as a slip, so that the same comparison can be made with it."""
650
+ return {'actions': p['actions'], 'limits': p['limits'], 'requires': [], 'never': [], 'validFrom': p['validFrom'], 'validUntil': p['validUntil']}
651
+
652
+
653
+ def _check_pass_entry(entry, value, slips, passes, ids, without, whole):
654
+ """A pass: an agent hands part of its permission to a helper agent."""
655
+ entry['signatures'] = []
656
+ # Whether this pass was compared with what its writer holds. It is not,
657
+ # if the pass or the slip could not be confirmed.
658
+ entry['compared'] = False
659
+ parsed = parse_record(value.get('pass'), 'pass')
660
+ entry['fingerprint'] = parsed.fingerprint
661
+ validate_pass_content(parsed.content)
662
+ c = parsed.content
663
+ entry['content'] = c
664
+ entry['slip'] = c['slip']
665
+ _note_id(entry, ids, c['id'])
666
+ slip = _find_slip(slips, c['slip'], whole)
667
+ # Who may pass on: the slip's own agent, or the helper named in the pass before.
668
+ parent = _find_pass(passes, c['from'], c['slip'], whole) if 'from' in c else None
669
+ entry['signatures'] = _check_key_set_signatures(parsed, parent['content']['to']['keys'] if parent else slip['content']['agent']['keys'], without)
670
+ if _overall(entry['signatures']) == 'invalid':
671
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the pass does not fit the keys of the agent that passes on.'})
672
+ entry['verified'] = _verified_by(entry['signatures'])
673
+ _under_confirmed_slip(entry, slip)
674
+ level = parent['level'] + 1 if parent else 1
675
+ # No slip can allow a permission to be passed on more than ten times in
676
+ # a row. A pass further down is refused, so that every stub can be
677
+ # compared with every pass above its own.
678
+ if level > MAX_PASSES:
679
+ entry['problems'].append({'code': 'pass-too-deep', 'message': 'A permission may be passed on at most ten times in a row, and this pass is one more.'})
680
+ allowed = slip['content']['passes'] if 'passes' in slip['content'] else 0
681
+ not_allowed = parent['notAllowed'] if parent else False
682
+
683
+ # What the pass shows about the agent that passed on.
684
+ if not entry['problems'] and entry['verified'] != 'none' and slip['verified']:
685
+ entry['compared'] = True
686
+ held = parent['content'] if parent else slip['content']
687
+ if level > allowed:
688
+ not_allowed = True
689
+ if allowed == 0:
690
+ message = 'The slip does not allow its permission to be passed on.'
691
+ else:
692
+ times = 'once' if allowed == 1 else f'{allowed} times'
693
+ message = f'The slip allows its permission to be passed on {times}, and this is one more.'
694
+ entry['breaches'].append({'code': 'pass-not-allowed', 'message': message})
695
+ if (
696
+ any(action not in held['actions'] for action in c['actions'])
697
+ or nan_time(c['validFrom']) < nan_time(held['validFrom'])
698
+ or nan_time(c['validUntil']) > nan_time(held['validUntil'])
699
+ ):
700
+ entry['breaches'].append({'code': 'pass-wider', 'message': 'The pass hands on more than the agent that wrote it holds: an action, or a time, outside its own permission.'})
701
+ when = nan_time(c['when'])
702
+ if when < nan_time(held['validFrom']) or when >= nan_time(held['validUntil']):
703
+ entry['breaches'].append({'code': 'outside-valid-time', 'message': "The pass is dated outside the time its writer's own permission allows."})
704
+ if slip['cancelled'] is not None:
705
+ entry['breaches'].append({'code': 'after-cancellation', 'message': 'The person cancelled the slip before this pass.'})
706
+ if parsed.fingerprint in passes:
707
+ entry['problems'].append({'code': 'duplicate-id', 'message': 'This pass is already in the book.'})
708
+ else:
709
+ passes[parsed.fingerprint] = {
710
+ 'content': c,
711
+ 'usable': len(entry['problems']) == 0,
712
+ # The pass this one was handed on from, if any.
713
+ 'parent': parent,
714
+ 'level': level,
715
+ 'notAllowed': not_allowed,
716
+ # The helper's own chain of stubs, and its running totals under the pass.
717
+ 'next': 0,
718
+ 'last': None,
719
+ 'lastWhen': -INF,
720
+ 'tally': new_tally(),
721
+ }
722
+
723
+
724
+ def _check_vouching_entry(entry, value, vouchings, ids, options):
725
+ """A vouching record: an organisation states that a key belongs to a name."""
726
+ without = _without(options)
727
+ entry['signatures'] = []
728
+ parsed = parse_record(value.get('vouching'), 'vouching')
729
+ entry['fingerprint'] = parsed.fingerprint
730
+ validate_vouching_content(parsed.content)
731
+ c = parsed.content
732
+ entry['content'] = c
733
+ _note_id(entry, ids, c['id'])
734
+ entry['signatures'] = _check_key_set_signatures(parsed, c['by']['keys'], without)
735
+ if _overall(entry['signatures']) == 'invalid':
736
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the vouching record does not fit the keys it gives.'})
737
+ entry['verified'] = _verified_by(entry['signatures'])
738
+ entry['signer'] = key_set_fingerprint(c['by']['keys'])
739
+ # It counts only if the checker named this organisation as one it trusts.
740
+ vouchers = options.get('vouchers')
741
+ entry['counted'] = isinstance(vouchers, list) and _js.includes(vouchers, entry['signer'])
742
+ if not entry['counted']:
743
+ entry['notes'].append(f"This vouching record is from an organisation you did not name as trusted, so it is not counted. The fingerprint of its key set is {entry['signer']}.")
744
+ if parsed.fingerprint in vouchings:
745
+ entry['problems'].append({'code': 'duplicate-id', 'message': 'This vouching record is already in the book.'})
746
+ else:
747
+ usable = not entry['problems'] and entry['verified'] != 'none'
748
+ vouching = {'content': c, 'signer': entry['signer'], 'usable': usable, 'counted': entry['counted'] and usable, 'withdrawn': False}
749
+ vouchings[parsed.fingerprint] = vouching
750
+ if usable:
751
+ key = _vouched_key(c['for']['kind'], c['for']['key'] if c['for']['kind'] == 'person' else c['for']['keys'], c['for']['name'])
752
+ found = vouchings.index.get(key) or {'counted': [], 'other': []}
753
+ vouchings.index[key] = found
754
+ # Only an organisation the checker named can add to the first list.
755
+ # Anyone can add to the second, so it is kept short.
756
+ if vouching['counted']:
757
+ found['counted'].append(vouching)
758
+ elif len(found['other']) < _MAX_UNCOUNTED:
759
+ found['other'].append(vouching)
760
+
761
+
762
+ def _check_withdrawal_entry(entry, value, vouchings, ids, options, whole):
763
+ """A withdrawal: the organisation ends one of its vouching records. From
764
+ this place in the book onwards that record vouches for nothing."""
765
+ without = _without(options)
766
+ entry['signatures'] = []
767
+ parsed = parse_record(value.get('withdrawal'), 'withdrawal')
768
+ entry['fingerprint'] = parsed.fingerprint
769
+ validate_withdrawal_content(parsed.content)
770
+ c = parsed.content
771
+ entry['content'] = c
772
+ _note_id(entry, ids, c['id'])
773
+ vouching = vouchings.get(c['vouching'])
774
+ if not vouching:
775
+ raise Refusal(
776
+ 'vouching-missing',
777
+ 'The vouching record this withdrawal ends is not earlier in the book.' if whole else 'The vouching record this withdrawal ends is not among the pages.',
778
+ )
779
+ # Only the organisation that vouched can withdraw.
780
+ entry['signatures'] = _check_key_set_signatures(parsed, vouching['content']['by']['keys'], without)
781
+ if _overall(entry['signatures']) == 'invalid':
782
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the withdrawal does not fit the keys of the organisation that vouched.'})
783
+ entry['verified'] = _verified_by(entry['signatures'])
784
+ entry['signer'] = vouching['signer']
785
+ if not entry['problems'] and entry['verified'] != 'none':
786
+ vouching['withdrawn'] = True
787
+
788
+
789
+ def _check_cancellation_entry(entry, value, slips, ids, options, whole):
790
+ """A cancellation: the person ends a slip early, with the passkey that signed it."""
791
+ without = _without(options)
792
+ entry['stamps'] = []
793
+ parsed = parse_record(value.get('cancellation'), 'cancellation')
794
+ entry['fingerprint'] = parsed.fingerprint
795
+ validate_cancellation_content(parsed.content)
796
+ c = parsed.content
797
+ entry['content'] = c
798
+ entry['slip'] = c['slip']
799
+ _note_id(entry, ids, c['id'])
800
+ slip = _find_slip(slips, c['slip'], whole)
801
+ alg = slip['content']['issuer']['key']['alg']
802
+ entry['signature'] = {'method': f'passkey ({alg})', 'alg': alg, 'state': 'invalid'}
803
+ try:
804
+ entry['signature']['state'] = check_passkey_signature(parsed, slip['content']['issuer'], without)
805
+ except Exception:
806
+ raise Refusal('cancellation-invalid', 'The cancellation was not signed with the passkey the slip names, or its passkey values do not check.') from None
807
+ entry['verified'] = 'all' if entry['signature']['state'] == 'valid' else 'none'
808
+ # A time-stamp is held against the cancellation's own date only where this device could confirm the cancellation.
809
+ confirmed = entry['verified'] == 'all' and slip['verified']
810
+ time = _check_stamps(entry, value['stamps'], parsed.fingerprint, nan_time(c['when']), options, confirmed) if 'stamps' in value else None
811
+ if time is not None and not entry['problems'] and not confirmed:
812
+ entry['notes'].append("The time-stamp beside this cancellation was not counted: this device could not check the cancellation's signature, or the slip's.")
813
+ if entry['problems'] or entry['verified'] == 'none' or not slip['verified']:
814
+ return
815
+ # Every sound cancellation counts. The slip is cancelled from the place
816
+ # of the first one in the book, and the earliest time that a counted
817
+ # time-stamp gives any of them is the time used for the stubs before it.
818
+ # So whoever keeps the book gains nothing by the order they stand in.
819
+ first = slip['cancelled'] is None
820
+ if first:
821
+ slip['cancelled'] = {'index': entry['index'], 'time': None}
822
+ else:
823
+ entry['notes'].append('The slip had already been cancelled, earlier in the book. Every cancellation counts: the earliest time that a time-stamp you trust gives any of them is the one used.')
824
+ if time is None:
825
+ if first:
826
+ entry['notes'].append('This cancellation has no time-stamp from a service you named as trusted. Only its place in the book says when it took effect.')
827
+ return
828
+ # The time of a cancellation is the earliest its time-stamps allow.
829
+ made = _cancelled_at(entry, nan_time(c['when']))
830
+ entry['stampedAt'] = format_time(made)
831
+ _note_late_stamp(entry, made, c['when'])
832
+ _note_block_set_aside(entry, nan_time(c['when']), _CANCELLATION_BLOCK_SET_ASIDE)
833
+ if not whole:
834
+ entry['notes'].append(
835
+ 'This cancellation is time-stamped. Single pages cannot show which stubs existed before that time: that needs the seals of the whole book. '
836
+ 'Here only the place of each page in the book is used.'
837
+ )
838
+ return
839
+ if slip['cancelled']['time'] is not None and slip['cancelled']['time'] <= made:
840
+ return
841
+ slip['cancelled']['time'] = made
842
+ _mark_not_shown_earlier(slip, made, 'This stub is not shown to have existed before the person cancelled the slip: no outside time-stamp from before the cancellation covers it.')
843
+
844
+
845
+ def _check_acknowledgement_entry(entry, value, slips, passes, ids, entries, without, whole):
846
+ """An acknowledgement: the agent's side states that it was handed the
847
+ person's cancellation of a slip, and when. It is signed with the keys of
848
+ the agent the slip names or, where it names a pass, of the helper agent
849
+ that pass names. In a book it comes after the cancellation it names."""
850
+ entry['signatures'] = []
851
+ parsed = parse_record(value.get('acknowledgement'), 'acknowledgement')
852
+ entry['fingerprint'] = parsed.fingerprint
853
+ validate_acknowledgement_content(parsed.content)
854
+ c = parsed.content
855
+ entry['content'] = c
856
+ entry['slip'] = c['slip']
857
+ _note_id(entry, ids, c['id'])
858
+ slip = _find_slip(slips, c['slip'], whole)
859
+ p = _find_pass(passes, c['pass'], c['slip'], whole) if 'pass' in c else None
860
+ if p:
861
+ entry['pass'] = c['pass']
862
+ entry['signatures'] = _check_key_set_signatures(parsed, p['content']['to']['keys'] if p else slip['content']['agent']['keys'], without)
863
+ if _overall(entry['signatures']) == 'invalid':
864
+ entry['problems'].append({
865
+ 'code': 'signature-invalid',
866
+ 'message': 'A signature on the acknowledgement does not fit the keys of the helper agent the pass names.'
867
+ if p
868
+ else "A signature on the acknowledgement does not fit the agent's keys.",
869
+ })
870
+ entry['verified'] = _verified_by(entry['signatures'])
871
+ _under_confirmed_slip(entry, slip)
872
+ # The cancellation it names: of the same slip, and sound.
873
+ named = next((e for e in entries if e is not entry and e['kind'] == 'cancellation' and e['fingerprint'] == c['cancellation']), None)
874
+ if named is None:
875
+ entry['problems'].append({
876
+ 'code': 'cancellation-not-found',
877
+ 'message': 'The cancellation this acknowledgement names is not earlier in the book.' if whole else 'The cancellation this acknowledgement names is not among the pages.',
878
+ })
879
+ elif named['problems'] or named.get('slip') != c['slip']:
880
+ entry['problems'].append({'code': 'cancellation-not-found', 'message': 'The cancellation this acknowledgement names did not pass its own check, or cancels another slip.'})
881
+ else:
882
+ entry['cancellation'] = named['index']
883
+
884
+
885
+ def _check_held_acknowledgements(held, items, fingerprint_text, slip_fingerprint, slip, passes, entries, without, confirmed):
886
+ """The acknowledgements handed over with the person's own copy of a
887
+ cancellation. Each must be for exactly that cancellation, and signed with
888
+ the keys of the slip's agent, or of a helper agent under a pass that the
889
+ book holds. One that does not check is a problem of its own: it is not
890
+ used, the reader is told, and the cancellation itself still counts. (The
891
+ agent's side cannot spoil the person's copy by handing them an
892
+ acknowledgement that does not check.)
893
+ "confirmed": whether this device confirmed the slip and the copy, and so
894
+ whose keys an acknowledgement is to be held against.
895
+ Returns the problems found."""
896
+ problems = []
897
+ try:
898
+ f.list_of(items, 1, MAX_ACKNOWLEDGEMENTS, 'acknowledgements')
899
+ except Exception as e:
900
+ return [problem_from(e)]
901
+ seen = set()
902
+ for record in items:
903
+ try:
904
+ parsed = parse_record(record, 'acknowledgement')
905
+ validate_acknowledgement_content(parsed.content)
906
+ a = parsed.content
907
+ if a['cancellation'] != fingerprint_text or a['slip'] != slip_fingerprint:
908
+ raise Refusal('acknowledgement-mismatch', 'An acknowledgement handed over with it was given for another cancellation, or under another slip.')
909
+ if parsed.fingerprint in seen:
910
+ raise f.fail('acknowledgements', 'the same acknowledgement is handed over twice.')
911
+ seen.add(parsed.fingerprint)
912
+ p = None
913
+ if 'pass' in a:
914
+ p = passes.get(a['pass'])
915
+ if not p or not p['usable'] or p['content']['slip'] != slip_fingerprint:
916
+ raise Refusal('pass-missing', 'The pass an acknowledgement names is not in the book, was given under another slip, or did not pass its own check.')
917
+ signatures = _check_key_set_signatures(parsed, p['content']['to']['keys'] if p else slip['content']['agent']['keys'], without)
918
+ state = _overall(signatures)
919
+ if state == 'invalid':
920
+ raise Refusal('signature-invalid', 'A signature on an acknowledgement handed over with it does not fit the keys of the agent it is said to be from.')
921
+ # The keys it was held against came from the slip: where this device could not confirm the slip or the copy, it confirmed nothing here either.
922
+ if not confirmed:
923
+ state = 'unavailable'
924
+ in_book = next((e for e in entries if e['kind'] == 'acknowledgement' and e['fingerprint'] == parsed.fingerprint), None)
925
+ held['acknowledgements'].append({
926
+ 'fingerprint': parsed.fingerprint,
927
+ 'by': 'helper' if p else 'agent',
928
+ 'pass': a['pass'] if p else None,
929
+ 'state': state,
930
+ 'signatures': signatures,
931
+ # What it says is given only where this device confirmed who signed it.
932
+ 'when': a['when'] if state == 'valid' else None,
933
+ 'inBook': in_book['index'] if in_book else None,
934
+ })
935
+ except Exception as e:
936
+ problems.append(problem_from(e))
937
+ return problems
938
+
939
+
940
+ _ACKNOWLEDGED = (
941
+ "the agent's side acknowledged that it was handed the person's cancellation of the slip. Both dates are the agent's own word. "
942
+ "The acknowledgement was handed over beside the book, with the person's own copy of the cancellation."
943
+ )
944
+
945
+
946
+ def _mark_after_acknowledged(slip, slip_fingerprint, passes, entries, acknowledgement, late, strict, added, under_passes):
947
+ """What an acknowledgement shows, where the person hands it over with their
948
+ own copy of the cancellation: the agent's side says it was told at that
949
+ time. A stub of that agent's own chain that is dated later was, by the
950
+ agent's own dates, made after it was told. So was a pass that the agent
951
+ handed on later, every pass handed on from such a pass, and every stub
952
+ under any of them: an agent that was told cannot go on through a helper.
953
+ Both dates are the agent's own word; the finding says so.
954
+
955
+ It is applied after the time-stamp rule (_mark_not_shown_earlier),
956
+ whichever order the copies were handed over in. A stub that the
957
+ time-stamp rule already reports keeps that finding, which rests on an
958
+ outside time-stamp, and the finding is added to."""
959
+
960
+ def marked(b):
961
+ return b['code'] == 'after-cancellation'
962
+
963
+ told = nan_time(acknowledgement['when'])
964
+
965
+ def handed_on_late(p):
966
+ """Whether a pass was handed on by the acknowledging agent after it was told, or comes from such a pass."""
967
+ while p:
968
+ writer = p['content']['from'] if 'from' in p['content'] else None
969
+ if writer == acknowledgement['pass'] and nan_time(p['content']['when']) > told:
970
+ return True
971
+ p = p['parent']
972
+ return False
973
+
974
+ def mark(entry, message):
975
+ more = late.get(entry['breaches'])
976
+ if any(marked(b) for b in entry['breaches']):
977
+ return
978
+ already = next((b for b in more if marked(b)), None) if more else None
979
+ if already is not None:
980
+ # Reported by the time-stamp rule: said as well, once.
981
+ if strict.has(already) and not added.has(already):
982
+ already['message'] += " It is also dated after the agent's side acknowledged the cancellation, by the agent's own dates."
983
+ added.set(already, True)
984
+ return
985
+ finding = {'code': 'after-cancellation', 'message': message}
986
+ if more is not None:
987
+ more.append(finding)
988
+ else:
989
+ late.set(entry['breaches'], [finding])
990
+
991
+ for stub in slip['stubs']:
992
+ p = passes.get(stub['pass']) if 'pass' in stub else None
993
+ if stub.get('pass') == acknowledgement['pass'] and nan_time(stub['content']['when']) > told:
994
+ if not under_passes:
995
+ mark(stub, f'This stub is dated after {_ACKNOWLEDGED}')
996
+ elif under_passes and p and handed_on_late(p):
997
+ mark(stub, f'This stub was written under a pass that was handed on after {_ACKNOWLEDGED}')
998
+ if not under_passes:
999
+ return
1000
+ for entry in entries:
1001
+ if entry['kind'] != 'pass' or entry.get('slip') != slip_fingerprint or entry.get('compared') is not True:
1002
+ continue
1003
+ p = passes.get(entry['fingerprint'])
1004
+ if p and handed_on_late(p):
1005
+ mark(entry, f'This pass was handed on, or comes from a pass that was handed on, after {_ACKNOWLEDGED}')
1006
+
1007
+
1008
+ def _note_late_stamp(entry, time, when):
1009
+ """The time-stamps stand beside a cancellation, and whoever keeps it could
1010
+ put a later one in the place of an earlier one. No check can show that. A
1011
+ gap between the person's own date and the time-stamp is what it would
1012
+ leave, so the gap is pointed out."""
1013
+ if time > nan_time(when) + CLOCK_ALLOWANCE_MS:
1014
+ entry['notes'].append(
1015
+ "The time-stamp on this cancellation states a time more than 300 seconds after the date the person's device gave. Whoever keeps the book "
1016
+ 'could have put a later time-stamp in the place of an earlier one. If the person holds an earlier time-stamp of this cancellation, that is the one to go by.'
1017
+ )
1018
+
1019
+
1020
+ def _mark_not_shown_earlier(slip, time, message, late=None, strict=None):
1021
+ """With a time-stamp, a cancellation has a proven time. A stub counts as
1022
+ made before it only if an outside time-stamp shows that it existed by
1023
+ then. An agent cannot escape a cancellation by writing an earlier time.
1024
+
1025
+ For a cancellation in the book the finding goes into the stub's own
1026
+ list. For one handed over beside the book it is gathered in "late", as
1027
+ the other findings are that are worked out afresh for each answer
1028
+ (_settle_late)."""
1029
+
1030
+ def marked(b):
1031
+ return b['code'] == 'after-cancellation'
1032
+
1033
+ for stub in slip['stubs']:
1034
+ more = late.get(stub['breaches']) if late is not None else None
1035
+ if any(marked(b) for b in stub['breaches']) or (more and any(marked(b) for b in more)):
1036
+ continue
1037
+ stamped = getattr(stub, 'stamped', None)
1038
+ if not ((INF if stamped is None else stamped) > time + CLOCK_ALLOWANCE_MS):
1039
+ continue
1040
+ finding = {'code': 'after-cancellation', 'message': message}
1041
+ # The findings made here for a copy handed over beside the book are noted, for _mark_after_acknowledged.
1042
+ if strict is not None:
1043
+ strict.set(finding, True)
1044
+ if late is None:
1045
+ stub['breaches'].append(finding)
1046
+ elif more is not None:
1047
+ more.append(finding)
1048
+ else:
1049
+ late.set(stub['breaches'], [finding])
1050
+
1051
+
1052
+ def _check_stamps(entry, stamps, fingerprint_text, when, options, counted=True, floor=-INF):
1053
+ """The outside time-stamps beside a record. Each states that the record
1054
+ existed at a time. Returns the earliest time stated by a service the
1055
+ checker trusts, or None."""
1056
+ without = _without(options)
1057
+ stamped = from_base64url(fingerprint_text)
1058
+ methods = {'without': [m for m in without if m in ('RSA', 'ECDSA', 'Ed25519')], 'trusted': options.get('stampServices')}
1059
+ for item in decode_stamps(stamps):
1060
+ try:
1061
+ if item['kind'] == 'block':
1062
+ stamp = dict(check_block_stamp(item['proof'], item['header'], stamped, options.get('blocks')))
1063
+ else:
1064
+ stamp = {'kind': 'service', **check_stamp(item['token'], stamped, methods)}
1065
+ # A service states one time. A block's time is loose, and has two (blockstamp.py).
1066
+ if stamp.get('earliest') is None:
1067
+ stamp['earliest'] = stamp['time']
1068
+ entry['stamps'].append(stamp)
1069
+ except Exception as e:
1070
+ entry['stamps'].append({'kind': item['kind'], 'state': 'invalid', 'when': None, 'time': NAN, 'authority': None})
1071
+ entry['problems'].append(problem_from(e))
1072
+ for s in entry['stamps']:
1073
+ if s['state'] != 'untrusted':
1074
+ continue
1075
+ entry['notes'].append(
1076
+ f"A block time-stamp on this record leads to a block you did not name as trusted. The block's fingerprint is {s['authority']}."
1077
+ if s['kind'] == 'block'
1078
+ else f"A time-stamp on this record is from a service you did not name as trusted. Its certificate's fingerprint is {s['authority']}."
1079
+ )
1080
+ giving = _giving_stamps(entry, when, floor)
1081
+ time = _js_min([s['time'] for s in giving]) if giving else None
1082
+ # A record dated later than the time-stamp that covers it is not as it
1083
+ # says. Said only where the time-stamp can be counted at all.
1084
+ if counted and time is not None and time < when - CLOCK_ALLOWANCE_MS:
1085
+ entry['problems'].append({'code': 'dated-after-stamp', 'message': 'This record is dated later than a time-stamp that covers it.'})
1086
+ return time
1087
+
1088
+
1089
+ def _behind_earlier_seal(s, floor):
1090
+ return s['kind'] == 'block' and s['time'] < floor - CLOCK_ALLOWANCE_MS
1091
+
1092
+
1093
+ def _giving_stamps(entry, when, floor=-INF):
1094
+ """The time-stamps that give a seal its time: every counted one, and the
1095
+ earliest time any of them states is the time by which the seal existed.
1096
+ (A block states its time and two hours.) Two cases are set apart, both
1097
+ of a block that states a time far behind the true time, which a service
1098
+ settles. The reader is told of each (_note_block_set_aside, _note_block_behind).
1099
+ - Beside a time-stamp from a service, a block that states a time more
1100
+ than 300 seconds before the seal's own date, even with its two hours,
1101
+ is set aside. With no service's time-stamp beside it, nothing settles
1102
+ it, and the record reads as dated after its time-stamp.
1103
+ - A block that states a time, even with its two hours, more than 300
1104
+ seconds before the latest time a service gave a seal before this one
1105
+ ("floor") is set aside. This seal covers that seal's line, time-stamp
1106
+ included, so it cannot have existed by then.
1107
+ (For a cancellation, the time it counts from is worked out by _cancelled_at.)"""
1108
+ valid = [s for s in entry['stamps'] if s['state'] == 'valid' and not _behind_earlier_seal(s, floor)]
1109
+ if not any(s['kind'] == 'service' for s in valid):
1110
+ return valid
1111
+ return [s for s in valid if s['kind'] == 'service' or not (s['time'] < when - CLOCK_ALLOWANCE_MS)]
1112
+
1113
+
1114
+ def _cancelled_at(entry, when):
1115
+ """The time of a cancellation: the earliest that any counted time-stamp
1116
+ beside it gives. A later time would excuse stubs made after the person
1117
+ cancelled, so every counted time-stamp is taken, of either kind: a later
1118
+ one put beside an earlier one changes nothing. A service's time is
1119
+ exact. A block's time is loose and is taken at its earliest, two hours
1120
+ before the time the block states; but never as earlier than the date the
1121
+ person signed in the cancellation itself, because by the person's own
1122
+ word it was not made before then. So nobody can move a cancellation back
1123
+ in time by having it put into a block before the date it gives."""
1124
+ times = [_js_max([s['earliest'], when]) if s['kind'] == 'block' else s['time'] for s in entry['stamps'] if s['state'] == 'valid']
1125
+ return _js_min(times) if times else None
1126
+
1127
+
1128
+ def _note_block_set_aside(entry, when, words):
1129
+ """Two witnesses that do not agree: beside a time-stamp from a service, a
1130
+ block time-stamp that states a time long before the record's own date,
1131
+ even with its two hours. The block is set aside (_giving_stamps,
1132
+ _cancelled_at), and the reader is told."""
1133
+ valid = [s for s in entry['stamps'] if s['state'] == 'valid']
1134
+ if any(s['kind'] == 'service' for s in valid) and any(s['kind'] == 'block' and s['time'] < when - CLOCK_ALLOWANCE_MS for s in valid):
1135
+ entry['notes'].append(words)
1136
+
1137
+
1138
+ _CANCELLATION_BLOCK_SET_ASIDE = (
1139
+ "A block time-stamp beside this cancellation states a time more than two hours before the cancellation's own date. The cancellation is not taken "
1140
+ 'to have been made before the date the person signed in it. Either the block states a time far behind the true time, which the chain\'s rules allow '
1141
+ 'in rare cases, or the cancellation was signed with a date later than the time it was made.'
1142
+ )
1143
+ _SEAL_BLOCK_SET_ASIDE = (
1144
+ "A block time-stamp beside this seal states a time more than two hours before the seal's own date. It was set aside, and the time-stamp from a "
1145
+ "service was used. Either the block states a time far behind the true time, which the chain's rules allow in rare cases, or the seal existed "
1146
+ 'before the date it gives.'
1147
+ )
1148
+
1149
+
1150
+ def _note_block_behind(entry, floor):
1151
+ """A block time-stamp that states a time before a service's time-stamp on a
1152
+ seal earlier in the book. It is set aside (_giving_stamps), and the
1153
+ reader is told."""
1154
+ if any(s['state'] == 'valid' and _behind_earlier_seal(s, floor) for s in entry['stamps']):
1155
+ entry['notes'].append(_SEAL_BLOCK_BEHIND)
1156
+
1157
+
1158
+ _SEAL_BLOCK_BEHIND = (
1159
+ 'A block time-stamp beside this seal states a time that is, even with its two hours, before the time a time-stamp service gave a seal that comes '
1160
+ 'before this one. This seal covers that seal and its time-stamp, so it cannot have existed by then. The block time-stamp was set aside: the block '
1161
+ "states a time far behind the true time, which the chain's rules allow in rare cases."
1162
+ )
1163
+
1164
+
1165
+ def _service_time(entry):
1166
+ """The earliest time that a counted time-stamp from a service gives a seal,
1167
+ or None where none does."""
1168
+ times = [s['time'] for s in entry['stamps'] if s['state'] == 'valid' and s['kind'] == 'service']
1169
+ return _js_min(times) if times else None
1170
+
1171
+
1172
+ def _compare_page_seals(pages):
1173
+ """In a Show, a seal among the pages cannot be compared with the entries
1174
+ before it, and its time-stamp is credited to no page. But the seal and
1175
+ the pages that come before it are each shown to be in the book, at their
1176
+ places. So their dates can be held against one another as in a whole
1177
+ book: where they do not fit, the whole book would fail its check, one
1178
+ way or another. Which of the two is at fault, single pages cannot show:
1179
+ the seal may not belong to the book at that place. So the finding is
1180
+ given for the seal, which is the page that could not be compared, and
1181
+ never for the page before it."""
1182
+ for seal in pages:
1183
+ if seal['kind'] != 'seal' or not seal.get('content') or seal['problems'] or seal['verified'] == 'none':
1184
+ continue
1185
+ when = nan_time(seal['content']['when'])
1186
+ time = nan_time(seal['stampedAt']) if seal.get('stampedAt') else None
1187
+ after = None
1188
+ before = None
1189
+ for page in pages:
1190
+ if not (page['index'] < seal['index']) or page['problems'] or page['verified'] == 'none':
1191
+ continue
1192
+ stated = _when_of(page)
1193
+ if time is not None and stated > time + CLOCK_ALLOWANCE_MS:
1194
+ if after is None:
1195
+ after = page['index']
1196
+ elif stated > when + CLOCK_ALLOWANCE_MS:
1197
+ if before is None:
1198
+ before = page['index']
1199
+ if after is not None:
1200
+ seal['problems'].append({
1201
+ 'code': 'dated-after-stamp',
1202
+ 'message': f"Page {after}, which comes before this seal in the book, is dated later than this seal's outside time-stamp. Either that page's "
1203
+ 'date is not as it says, or this seal does not fit the entries before it. Single pages cannot show which.',
1204
+ })
1205
+ if before is not None:
1206
+ seal['problems'].append({
1207
+ 'code': 'time-went-backwards',
1208
+ 'message': f'This seal is dated before page {before}, which comes before it in the book. Either one of the two dates is not as it says, '
1209
+ 'or this seal does not fit the entries before it.',
1210
+ })
1211
+ _later_than_seals(seal, pages)
1212
+ if seal['problems']:
1213
+ seal['verified'] = 'none'
1214
+
1215
+
1216
+ def _later_than_seals(seal, pages):
1217
+ """In a Show: a seal against the seals among the pages that come before it
1218
+ in the book. Its date may not be earlier than theirs, nor the time a
1219
+ service gave it more than 300 seconds earlier than a service gave them.
1220
+ And the seals form a chain: a seal names the seal just before it."""
1221
+ when = nan_time(seal['content']['when'])
1222
+ service = _service_time(seal) if seal['verified'] == 'all' else None
1223
+ dated = False
1224
+ stamped = False
1225
+ for p in pages:
1226
+ if p is seal or p['kind'] != 'seal' or not p.get('content') or not (p['index'] < seal['index']) or p['problems'] or p['verified'] == 'none':
1227
+ continue
1228
+ if when < nan_time(p['content']['when']):
1229
+ dated = True
1230
+ earlier = _service_time(p) if p['verified'] == 'all' else None
1231
+ if service is not None and earlier is not None and service < earlier - CLOCK_ALLOWANCE_MS:
1232
+ stamped = True
1233
+ if dated:
1234
+ seal['problems'].append({'code': 'time-went-backwards', 'message': 'This seal is dated before a seal, among the pages, that comes before it in the book. One of the two is not as it says.'})
1235
+ if stamped:
1236
+ seal['problems'].append({
1237
+ 'code': 'time-went-backwards',
1238
+ 'message': "This seal's time-stamp is earlier than the time-stamp of a seal, among the pages, that comes before it in the book. One of the two is not as it says.",
1239
+ })
1240
+ # The chain of seals, as far as the pages show it. Every seal that could be read has its place in it.
1241
+ others = [p for p in pages if p is not seal and p['kind'] == 'seal' and p.get('content') and p['fingerprint']]
1242
+ earlier_seals = [p for p in others if p['index'] < seal['index']]
1243
+ if 'previous' not in seal['content']:
1244
+ # It says it is the first seal of the book, and a seal comes before it.
1245
+ broken = len(earlier_seals) > 0
1246
+ else:
1247
+ # If the seal it names is among the pages, that seal comes before it, with no other seal between the two.
1248
+ named = next((p for p in others if p['fingerprint'] == seal['content']['previous']), None)
1249
+ broken = named is not None and (not (named['index'] < seal['index']) or any(p['index'] > named['index'] for p in earlier_seals))
1250
+ if broken:
1251
+ seal['problems'].append({'code': 'seal-chain-broken', 'message': 'The seal does not name the seal before it among the pages: a seal is missing, moved or changed.'})
1252
+
1253
+
1254
+ def _counted_by(entry, floor):
1255
+ """The kind of the counted time-stamp that gives a seal its time: the one
1256
+ that states the earliest. A service's, where the two state the same."""
1257
+ giving = _giving_stamps(entry, nan_time(entry['content']['when']), floor)
1258
+ if not giving:
1259
+ return 'service'
1260
+ earliest = _js_min([s['time'] for s in giving])
1261
+ return 'service' if any(s['kind'] == 'service' and s['time'] == earliest for s in giving) else 'block'
1262
+
1263
+
1264
+ def _dated_before_covered(when, covered, count, credited_time):
1265
+ """Whether a seal is dated before an entry it covers, where no counted
1266
+ time-stamp settles which of the two dates is wrong. An entry that failed
1267
+ its own check says nothing. One dated after the counted time-stamp is
1268
+ marked itself (_credit_stamp), and is not counted here."""
1269
+ for i in range(count):
1270
+ e = covered[i]
1271
+ if e['problems'] or e['verified'] == 'none':
1272
+ continue
1273
+ stated = _when_of(e)
1274
+ if not (stated > when + CLOCK_ALLOWANCE_MS):
1275
+ continue
1276
+ if credited_time is not None and stated > credited_time + CLOCK_ALLOWANCE_MS:
1277
+ continue
1278
+ return True
1279
+ return False
1280
+
1281
+
1282
+ def latest_when(entry):
1283
+ """The latest time a checked entry states, in milliseconds; NaN if it states none."""
1284
+ return _when_of(entry)
1285
+
1286
+
1287
+ def _when_of(entry):
1288
+ """The latest time an entry states: its own, and that of a countersignature
1289
+ or an approval on the same line. NaN if it states none."""
1290
+ times = []
1291
+ for holder in (entry.get('content'), entry.get('countersignature'), entry.get('approval')):
1292
+ t = holder.get('when') if isinstance(holder, dict) else None
1293
+ if isinstance(t, str):
1294
+ ms = nan_time(t)
1295
+ if ms == ms:
1296
+ times.append(ms)
1297
+ return _js_max(times) if times else NAN
1298
+
1299
+
1300
+ def _credit_stamp(earlier, stamped_at, time):
1301
+ """An entry that a trusted time-stamp covers existed by the stated time. One
1302
+ that is dated later is not as it says. An entry keeps the earliest time
1303
+ that any seal after it shows, as "existedBy". Returns False if the entry
1304
+ was already shown to have existed by then."""
1305
+ before = getattr(earlier, 'stamped', None)
1306
+ if before is not None and before <= time:
1307
+ return False
1308
+ earlier.stamped = time
1309
+ # The time that a seal's or a cancellation's own time-stamps give it is
1310
+ # another matter, and is kept apart, as "stampedAt": a cancellation does
1311
+ # not count from the time of a seal after it.
1312
+ earlier['existedBy'] = stamped_at
1313
+ if _when_of(earlier) > time + CLOCK_ALLOWANCE_MS and not any(p['code'] == 'dated-after-stamp' for p in earlier['problems']):
1314
+ earlier['problems'].append({'code': 'dated-after-stamp', 'message': 'This entry is dated later than the outside time-stamp that covers it.'})
1315
+ earlier['verified'] = 'none'
1316
+ return True
1317
+
1318
+
1319
+ def _check_seal_entry(entry, value, seals, ids, options, before, entries, as_page=False):
1320
+ """A seal: the recorder's signed statement of the top fingerprint and the
1321
+ number of entries before it, with outside time-stamps beside it.
1322
+
1323
+ seals: the state of the seals so far, or a fresh state for a seal that
1324
+ stands alone. before: works out the entries before the seal, where they
1325
+ are at hand. entries: the entries so far, so that the ones a time-stamp
1326
+ covers can be marked. as_page: whether the seal is a single page of a
1327
+ Show, with nothing to compare it with."""
1328
+ without = _without(options)
1329
+ entry['signatures'] = []
1330
+ entry['stamps'] = []
1331
+ parsed = parse_record(value.get('seal'), 'seal')
1332
+ entry['fingerprint'] = parsed.fingerprint
1333
+ validate_seal_content(parsed.content)
1334
+ c = parsed.content
1335
+ entry['content'] = c
1336
+ _note_id(entry, ids, c['id'])
1337
+ seals['count'] += 1
1338
+
1339
+ # The recorder's three signatures: all must check.
1340
+ entry['signatures'] = _check_key_set_signatures(parsed, c['by']['keys'], without)
1341
+ if _overall(entry['signatures']) == 'invalid':
1342
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the seal does not fit the keys the seal gives.'})
1343
+ entry['verified'] = _verified_by(entry['signatures'])
1344
+ entry['sealer'] = key_set_fingerprint(c['by']['keys'])
1345
+ seal_keys = options.get('sealKeys')
1346
+ if isinstance(seal_keys, list):
1347
+ if not _js.includes(seal_keys, entry['sealer']):
1348
+ entry['problems'].append({'code': 'sealer-not-expected', 'message': 'The seal was signed with keys other than the ones expected.'})
1349
+ else:
1350
+ entry['notes'].append('The keys that signed this seal were not compared with keys you already trust. The name on the seal is only a label.')
1351
+
1352
+ # The seal against the entries before it, and against the seal before it.
1353
+ when = nan_time(c['when'])
1354
+ at = before() if before else None
1355
+ # A seal sits after exactly as many entries as it covers.
1356
+ if not at and c['size'] != entry['index']:
1357
+ entry['problems'].append({'code': 'seal-mismatch', 'message': 'A seal sits after exactly as many entries as it covers, and this one does not.'})
1358
+ if as_page:
1359
+ entry['notes'].append(
1360
+ 'This seal is shown as a single page. It could not be compared with the entries before it, so its time-stamp is not taken to show when any '
1361
+ 'other page existed. Its dates are still held against the pages that come before it in the book.'
1362
+ )
1363
+ if at:
1364
+ if c['size'] != at['size'] or c['root'] != at['root']:
1365
+ entry['problems'].append({'code': 'seal-mismatch', 'message': 'The seal does not fit the entries before it: an entry was changed, added or removed after the seal was made.'})
1366
+ if (c['previous'] if 'previous' in c else None) != seals['last']:
1367
+ entry['problems'].append({'code': 'seal-chain-broken', 'message': 'The seal does not name the seal before it: a seal is missing, moved or changed.'})
1368
+ if when < seals['lastWhen']:
1369
+ entry['problems'].append({'code': 'time-went-backwards', 'message': 'This seal is dated before the seal ahead of it.'})
1370
+ seals['last'] = parsed.fingerprint
1371
+ seals['lastWhen'] = when
1372
+
1373
+ # The outside time-stamps. Each states that this seal existed at a time.
1374
+ # They are read only for a seal whose own signatures did not fail. The
1375
+ # latest time a service gave a seal before this one is the "floor": a
1376
+ # block time-stamp that states a time before it is set aside (_giving_stamps).
1377
+ floor = seals['lastStampTime'] if seals['lastStampTime'] is not None else -INF
1378
+ # The same floor holds for the seal's own date. A seal covers the lines of
1379
+ # the seals before it, time-stamps included, so it cannot have been made
1380
+ # before the latest time a service gave one of them.
1381
+ if when < floor - CLOCK_ALLOWANCE_MS:
1382
+ entry['problems'].append({
1383
+ 'code': 'time-went-backwards',
1384
+ 'message': 'This seal is dated before the time that a time-stamp service gave a seal that comes before it. It covers that seal and its '
1385
+ 'time-stamp, so it cannot have been made by then.',
1386
+ })
1387
+ stamped_time = None
1388
+ if 'stamps' in value and _overall(entry['signatures']) != 'invalid':
1389
+ stamped_time = _check_stamps(entry, value['stamps'], parsed.fingerprint, when, options, entry['verified'] == 'all', floor)
1390
+ if 'stamps' in value and _overall(entry['signatures']) == 'invalid':
1391
+ decode_stamps(value['stamps'])
1392
+
1393
+ # A seal counts as signed only if all three of its signatures were
1394
+ # checked. Only then is a time-stamp on it credited.
1395
+ credited = not entry['problems'] and entry['verified'] == 'all' and stamped_time is not None
1396
+ if stamped_time is not None and not entry['problems'] and entry['verified'] != 'all':
1397
+ entry['notes'].append("The time-stamp beside this seal was not counted: this device could not check all three of the seal's signatures.")
1398
+ if not entry['problems'] and entry['verified'] == 'all':
1399
+ _note_block_behind(entry, floor)
1400
+
1401
+ # A seal dated before an entry it covers: one of the two dates is not as
1402
+ # it says. Where a counted time-stamp settles which (the entry is dated
1403
+ # after the time-stamp too), the entry is marked instead (below). Where
1404
+ # none does, the seal is where the two meet.
1405
+ if at and seals['latest'] > when + CLOCK_ALLOWANCE_MS and _dated_before_covered(when, entries, len(entries) - 1, stamped_time if credited else None):
1406
+ entry['problems'].append({'code': 'time-went-backwards', 'message': 'This seal is dated before an entry it covers. One of the two dates is not as it says.'})
1407
+ if not credited or entry['problems']:
1408
+ return
1409
+ time = stamped_time
1410
+ entry['stampedAt'] = format_time(time)
1411
+ _note_block_set_aside(entry, when, _SEAL_BLOCK_SET_ASIDE)
1412
+ service = _service_time(entry)
1413
+ if not at:
1414
+ # Among the pages of a Show, the services' times are held against one
1415
+ # another later (_later_than_seals). The floor for a block time-stamp on
1416
+ # a seal further on is kept here, as in a whole book.
1417
+ if service is not None and service > (seals['lastStampTime'] if seals['lastStampTime'] is not None else -INF):
1418
+ seals['lastStampTime'] = service
1419
+ return
1420
+ # A later seal cannot have been time-stamped by a service before an
1421
+ # earlier one: not before the latest time a service gave any seal so
1422
+ # far. A block time-stamp is not reported in this way: it is made hours
1423
+ # after the seal, and its time is only right to within two hours. One
1424
+ # that states a time before that floor is set aside (_giving_stamps).
1425
+ if service is not None:
1426
+ if service < seals['lastStampTime'] - CLOCK_ALLOWANCE_MS:
1427
+ entry['problems'].append({'code': 'time-went-backwards', 'message': "This seal's time-stamp is earlier than the time-stamp of a seal before it."})
1428
+ return
1429
+ if service > seals['lastStampTime']:
1430
+ seals['lastStampTime'] = service
1431
+ by = _counted_by(entry, floor)
1432
+ # What a trusted time-stamp covers: every entry before the seal existed
1433
+ # by then. An entry dated later than that is not as it says. The
1434
+ # earliest time that any seal after an entry shows is the one that
1435
+ # counts for it. Going back from the seal, the entries already shown to
1436
+ # have existed earlier are left as they are, with everything before them.
1437
+ for i in range(len(entries) - 2, -1, -1):
1438
+ if not _credit_stamp(entries[i], entry['stampedAt'], time):
1439
+ break
1440
+ seals['next'] = len(entries) - 1
1441
+ seals['sealed'] = {'entries': c['size'], 'when': entry['stampedAt'], 'seal': entry['index'], 'by': by}
1442
+
1443
+
1444
+ # A stub may have with it, on the same line, the service's countersignature
1445
+ # and the person's approval.
1446
+ _STUB_ENTRIES = ['stub', 'countersignature,stub', 'approval,stub', 'approval,countersignature,stub']
1447
+
1448
+
1449
+ def _find_slip(slips, fp, whole):
1450
+ slip = slips.get(fp)
1451
+ if not slip:
1452
+ raise Refusal('slip-missing', 'The slip this entry relies on is not earlier in the book.' if whole else 'The slip this entry relies on is not among the pages.')
1453
+ if not slip['usable']:
1454
+ raise Refusal('slip-unusable', 'The slip this entry relies on did not pass its own check.')
1455
+ return slip
1456
+
1457
+
1458
+ def _under_confirmed_slip(entry, slip):
1459
+ """What sits under a slip this device could not confirm is not evidence
1460
+ either, whatever its own signatures say: the keys it was checked against
1461
+ came from that slip."""
1462
+ if slip['verified'] or entry['verified'] == 'none':
1463
+ return
1464
+ entry['verified'] = 'none'
1465
+ entry['notes'].append('The slip this entry relies on could not be confirmed on this device, so this entry is not shown.')
1466
+
1467
+
1468
+ def check_approval_record(record, request, slip_fingerprint, slip, ids, without):
1469
+ """Check the person's approval of one action against that action: it must be
1470
+ for exactly this request, under this slip, not used before, and signed
1471
+ with the passkey the slip names. Used by the checker and by the check
1472
+ before acting. Returns {state, when, alg, fingerprint}; raises a Refusal."""
1473
+ approval = parse_record(record, 'approval')
1474
+ validate_approval_content(approval.content)
1475
+ a = approval.content
1476
+ if a['slip'] != slip_fingerprint or request_of(a) != request_of(request):
1477
+ raise Refusal('approval-mismatch', 'The approval was given for something else.')
1478
+ if 'approval' in request and request['approval'] != approval.fingerprint:
1479
+ raise Refusal('approval-mismatch', 'The approval with this stub is not the one the stub names.')
1480
+ if a['id'] in ids.approvals:
1481
+ raise Refusal('approval-reused', 'This approval has already been used for another stub.')
1482
+ if a['id'] in ids:
1483
+ raise Refusal('duplicate-id', 'Another record has the same unique number.')
1484
+ ids.add(a['id'])
1485
+ ids.approvals.add(a['id'])
1486
+ try:
1487
+ state = check_passkey_signature(approval, slip['content']['issuer'], without)
1488
+ except Exception:
1489
+ raise Refusal('approval-invalid', 'The approval was not signed with the passkey the slip names, or its passkey values do not check.') from None
1490
+ return {'state': state, 'when': a['when'], 'alg': slip['content']['issuer']['key']['alg'], 'fingerprint': approval.fingerprint}
1491
+
1492
+
1493
+ def check_countersignature(record, stub_fingerprint, service, without, into=None):
1494
+ """Check a countersignature against the stub it was made for and the keys
1495
+ the slip gives for the service. Used by the checker, and by the stub
1496
+ writer before it puts a countersignature into its book. "into" is filled
1497
+ in as the check goes. Returns {state, signatures, when}; raises a Refusal."""
1498
+ if into is None:
1499
+ into = {'state': 'invalid', 'signatures': []}
1500
+ counter = parse_record(record, 'countersignature')
1501
+ validate_countersignature_content(counter.content)
1502
+ if counter.content['stub'] != stub_fingerprint:
1503
+ raise Refusal('countersignature-wrong-stub', 'The countersignature was made for a different stub.')
1504
+ if not keys_of(service):
1505
+ raise Refusal('countersignature-not-possible', 'The slip names no keys for a service that could have countersigned this stub.')
1506
+ into['when'] = counter.content['when']
1507
+ into['signatures'] = _check_key_set_signatures(counter, service['keys'], without)
1508
+ into['state'] = _overall(into['signatures'])
1509
+ if into['state'] == 'invalid':
1510
+ raise Refusal('countersignature-invalid', "A signature on the countersignature does not fit the service's keys.")
1511
+ return into
1512
+
1513
+
1514
+ def _note_id(entry, ids, record_id):
1515
+ if record_id in ids:
1516
+ entry['problems'].append({'code': 'duplicate-id', 'message': 'Another record has the same unique number.'})
1517
+ ids.add(record_id)
1518
+
1519
+
1520
+ def _check_refusal_entry(entry, value, slips, ids, without, whole):
1521
+ """A refusal: what a service says it refused. It is the service's own
1522
+ statement, signed with the keys the refusal itself gives."""
1523
+ entry['signatures'] = []
1524
+ parsed = parse_record(value.get('refusal'), 'refusal')
1525
+ entry['fingerprint'] = parsed.fingerprint
1526
+ validate_refusal_content(parsed.content)
1527
+ c = parsed.content
1528
+ entry['content'] = c
1529
+ entry['slip'] = c['slip']
1530
+ _note_id(entry, ids, c['id'])
1531
+ slip = _find_slip(slips, c['slip'], whole)
1532
+ entry['signatures'] = _check_key_set_signatures(parsed, c['by']['keys'], without)
1533
+ if _overall(entry['signatures']) == 'invalid':
1534
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the refusal does not fit the keys the refusal gives.'})
1535
+ entry['verified'] = _verified_by(entry['signatures'])
1536
+ entry['signer'] = key_set_fingerprint(c['by']['keys'])
1537
+ _under_confirmed_slip(entry, slip)
1538
+ # Is this a service the slip names? Only its keys can say.
1539
+ keys = canonical_json(c['by']['keys'])
1540
+ named = next((s for s in slip['content']['with'] if keys_of(s) and canonical_json(s['keys']) == keys), None)
1541
+ entry['service'] = named['id'] if named else None
1542
+
1543
+
1544
+ def _check_terms_entry(entry, value, terms, ids, without):
1545
+ """A service's terms for agents, signed with the keys the terms themselves give."""
1546
+ entry['signatures'] = []
1547
+ parsed = parse_record(value.get('terms'), 'terms')
1548
+ entry['fingerprint'] = parsed.fingerprint
1549
+ try:
1550
+ validate_terms_content(parsed.content)
1551
+ c = parsed.content
1552
+ entry['content'] = c
1553
+ _note_id(entry, ids, c['id'])
1554
+ entry['signatures'] = _check_key_set_signatures(parsed, c['by']['keys'], without)
1555
+ if _overall(entry['signatures']) == 'invalid':
1556
+ entry['problems'].append({'code': 'signature-invalid', 'message': 'A signature on the terms does not fit the keys the terms give.'})
1557
+ entry['verified'] = _verified_by(entry['signatures'])
1558
+ # 5. Whose keys signed these terms: a name is only a label.
1559
+ entry['signer'] = key_set_fingerprint(c['by']['keys'])
1560
+ except Exception as e:
1561
+ entry['problems'].append(problem_from(e))
1562
+ if parsed.fingerprint in terms:
1563
+ entry['problems'].append({'code': 'duplicate-id', 'message': 'These terms are already in the book.'})
1564
+ else:
1565
+ terms[parsed.fingerprint] = {
1566
+ 'content': entry.get('content'),
1567
+ 'usable': len(entry['problems']) == 0,
1568
+ 'verified': len(entry['problems']) == 0 and entry['verified'] != 'none',
1569
+ }
1570
+
1571
+
1572
+ def _check_stub_entry(entry, value, slips, terms, passes, ids, without, whole):
1573
+ entry['signatures'] = []
1574
+ # A countersignature the line holds is "unchecked" until it is checked,
1575
+ # so that a stub whose slip fails is not counted as one-sided.
1576
+ entry['countersignature'] = {'state': 'unchecked' if 'countersignature' in value else 'absent', 'signatures': []}
1577
+ entry['approval'] = {'state': 'absent'}
1578
+ # Whether this stub was compared with its slip. It is not, if the stub or
1579
+ # the slip could not be confirmed.
1580
+ entry['compared'] = False
1581
+
1582
+ parsed = parse_record(value.get('stub'), 'stub')
1583
+ entry['fingerprint'] = parsed.fingerprint
1584
+ validate_stub_content(parsed.content)
1585
+ c = parsed.content
1586
+ entry['content'] = c
1587
+ entry['slip'] = c['slip']
1588
+
1589
+ _note_id(entry, ids, c['id'])
1590
+ slip = _find_slip(slips, c['slip'], whole)
1591
+
1592
+ # A helper agent's stub names the pass it acts under: it is signed with
1593
+ # the helper's keys, and sits in the helper's own chain.
1594
+ p = _find_pass(passes, c['pass'], c['slip'], whole) if 'pass' in c else None
1595
+ chain = p if p else slip
1596
+ if p:
1597
+ entry['pass'] = c['pass']
1598
+
1599
+ # The agent's two signatures: both must check.
1600
+ entry['signatures'] = _check_key_set_signatures(parsed, p['content']['to']['keys'] if p else slip['content']['agent']['keys'], without)
1601
+ if _overall(entry['signatures']) == 'invalid':
1602
+ entry['problems'].append({
1603
+ 'code': 'signature-invalid',
1604
+ 'message': 'A signature on the stub does not fit the keys of the helper agent the pass names.' if p else "A signature on the stub does not fit the agent's keys.",
1605
+ })
1606
+ entry['verified'] = _verified_by(entry['signatures'])
1607
+ _under_confirmed_slip(entry, slip)
1608
+
1609
+ # Its place in the chain.
1610
+ when = nan_time(c['when'])
1611
+ if whole:
1612
+ if c['seq'] != chain['next'] or (c['seq'] > 0 and c.get('previous') != chain['last']):
1613
+ entry['problems'].append({'code': 'chain-broken', 'message': 'The chain is broken here: a stub is missing, moved, repeated or changed.'})
1614
+ if when < chain['lastWhen']:
1615
+ entry['problems'].append({'code': 'time-went-backwards', 'message': 'This stub is dated before the stub ahead of it.'})
1616
+ # Carry on from this stub, so that one break is reported once.
1617
+ chain['next'] = c['seq'] + 1
1618
+ chain['last'] = parsed.fingerprint
1619
+ chain['lastWhen'] = when
1620
+
1621
+ # The countersignature, if there is one.
1622
+ service = next((s for s in slip['content']['with'] if s['id'] == c['with']), None) if 'with' in c else None
1623
+ if 'countersignature' in value:
1624
+ entry['countersignature']['state'] = 'invalid'
1625
+ try:
1626
+ check_countersignature(value['countersignature'], parsed.fingerprint, service, without, entry['countersignature'])
1627
+ except Exception as e:
1628
+ entry['problems'].append(problem_from(e))
1629
+
1630
+ # The person's approval of this one action, if there is one. It is signed
1631
+ # with the passkey the slip names, and must be for exactly this action.
1632
+ if 'approval' in value:
1633
+ entry['approval']['state'] = 'invalid'
1634
+ try:
1635
+ # A stub must name the approval that is with it.
1636
+ if 'approval' not in c:
1637
+ raise Refusal('approval-mismatch', 'The stub does not name the approval that is with it.')
1638
+ checked = check_approval_record(value['approval'], c, c['slip'], slip, ids, without)
1639
+ entry['approval'] = {'state': checked['state'], 'when': checked['when'], 'alg': checked['alg']}
1640
+ # The stub names the approval, so it was signed after the approval
1641
+ # was written. A stub dated before its approval is not as it says.
1642
+ if nan_time(checked['when']) > when + CLOCK_ALLOWANCE_MS:
1643
+ raise Refusal('approval-dated-after-stub', 'The approval is dated later than the stub that carries it.')
1644
+ except Exception as e:
1645
+ entry['problems'].append(problem_from(e))
1646
+ elif 'approval' in c:
1647
+ entry['problems'].append({'code': 'approval-not-found', 'message': 'The stub names an approval that is not with it.'})
1648
+
1649
+ # The service's terms for agents, if the stub relies on them.
1650
+ terms_content = None
1651
+ terms_confirmed = True
1652
+ if 'terms' in c:
1653
+ t = terms.get(c['terms'])
1654
+ if not t:
1655
+ entry['problems'].append({
1656
+ 'code': 'terms-not-found',
1657
+ 'message': 'The terms this stub relies on are not earlier in the book.' if whole else 'The terms this stub relies on are not among the pages.',
1658
+ })
1659
+ elif not t['usable']:
1660
+ entry['problems'].append({'code': 'terms-unusable', 'message': 'The terms this stub relies on did not pass their own check.'})
1661
+ elif not keys_of(service) or canonical_json(service['keys']) != canonical_json(t['content']['by']['keys']):
1662
+ entry['problems'].append({'code': 'terms-mismatch', 'message': 'The terms this stub relies on were not signed with the keys the slip gives for the service it names.'})
1663
+ else:
1664
+ terms_content = t['content']
1665
+ terms_confirmed = t['verified']
1666
+
1667
+ # What the stub shows about the agent. Only a stub that is itself sound,
1668
+ # that this device could confirm at least in part, under a slip this
1669
+ # device confirmed, is evidence of anything.
1670
+ if entry['problems'] or entry['verified'] == 'none' or not slip['verified'] or not terms_confirmed:
1671
+ return
1672
+ entry['compared'] = True
1673
+ shown = compare_with_slip(
1674
+ slip['content'],
1675
+ slip['tally'] if whole else None,
1676
+ c,
1677
+ {'service': service, 'terms': terms_content, 'cancelled': slip['cancelled'] is not None, 'late': entry['breaches']},
1678
+ )
1679
+ slip['stubs'].append(entry)
1680
+ # A helper's stub is compared with the slip, as every stub is, and also
1681
+ # with the pass it acts under and with every pass above that one: what a
1682
+ # helper holds, it holds under each of them, and their totals take in
1683
+ # what is done further down. A finding already given is not repeated.
1684
+ # (There are at most ten: a pass further down is refused.)
1685
+ if p:
1686
+ given = list(shown['breaches'])
1687
+ q = p
1688
+ while q:
1689
+ under = compare_with_slip(
1690
+ pass_as_slip(q['content']),
1691
+ q['tally'] if whole else None,
1692
+ c,
1693
+ {'service': service, 'what': 'pass' if q is p else 'earlier-pass', 'late': entry['breaches']},
1694
+ )
1695
+ for b in under['breaches']:
1696
+ if any(x['code'] == b['code'] for x in given):
1697
+ continue
1698
+ given.append(b)
1699
+ entry['breaches'].append(b)
1700
+ q = q['parent']
1701
+ if p['notAllowed']:
1702
+ entry['breaches'].append({'code': 'pass-not-allowed', 'message': 'This stub was written under a pass that the slip does not allow.'})
1703
+ entry['breaches'].extend(shown['breaches'])
1704
+ if shown['running'] is not None:
1705
+ entry['running'] = shown['running']
1706
+ entry['needs'] = shown['needs']
1707
+ if 'countersignature' in shown['needs'] and entry['countersignature']['state'] == 'absent':
1708
+ entry['breaches'].append({'code': 'countersignature-missing', 'message': 'The slip asks for the other side to countersign this action, and there is no countersignature.'})
1709
+ if 'approval' in shown['needs'] and entry['approval']['state'] == 'absent':
1710
+ entry['breaches'].append({'code': 'approval-missing', 'message': "The slip asks for the person's own approval of this action, and there is none."})
1711
+
1712
+
1713
+ def _summarise(entries, book_problems, sealed=None, held=()):
1714
+ # Methods this device lacked, and whether any signature was never reached
1715
+ # because its entry, or the whole record, could not be read or used.
1716
+ missing = set()
1717
+ skipped = len(book_problems) > 0 or len(entries) == 0
1718
+ counts = {
1719
+ 'slips': 0, 'stubs': 0, 'countersigned': 0, 'oneSided': 0, 'approved': 0, 'refusals': 0,
1720
+ 'terms': 0, 'seals': 0, 'cancellations': 0, 'acknowledgements': 0, 'vouchings': 0, 'passes': 0,
1721
+ }
1722
+
1723
+ def note(signatures):
1724
+ nonlocal skipped
1725
+ if len(signatures) == 0:
1726
+ skipped = True
1727
+ for s in signatures:
1728
+ if s['state'] == 'unavailable':
1729
+ missing.add(s['method'])
1730
+
1731
+ for e in entries:
1732
+ kind = e['kind']
1733
+ if kind == 'slip':
1734
+ counts['slips'] += 1
1735
+ if e['signature']['state'] == 'unavailable':
1736
+ missing.add(e['signature']['alg'])
1737
+ elif e['signature']['state'] == 'unchecked':
1738
+ skipped = True
1739
+ elif kind == 'stub':
1740
+ counts['stubs'] += 1
1741
+ note(e.get('signatures') or [])
1742
+ counter = e.get('countersignature') or {'state': 'absent', 'signatures': []}
1743
+ if counter['state'] == 'absent':
1744
+ counts['oneSided'] += 1
1745
+ else:
1746
+ note(counter['signatures'])
1747
+ # Counted only when both of its signatures were confirmed here.
1748
+ if counter['state'] == 'valid':
1749
+ counts['countersigned'] += 1
1750
+ approval = e.get('approval') or {'state': 'absent'}
1751
+ if approval['state'] == 'unavailable':
1752
+ missing.add(approval['alg'])
1753
+ elif approval['state'] == 'valid':
1754
+ counts['approved'] += 1
1755
+ elif kind in ('refusal', 'terms'):
1756
+ counts['refusals' if kind == 'refusal' else 'terms'] += 1
1757
+ note(e.get('signatures') or [])
1758
+ elif kind == 'seal':
1759
+ counts['seals'] += 1
1760
+ note(e.get('signatures') or [])
1761
+ for s in e.get('stamps') or []:
1762
+ if s['state'] == 'unavailable':
1763
+ missing.add("the time-stamp service's method")
1764
+ elif kind == 'cancellation':
1765
+ counts['cancellations'] += 1
1766
+ if not e.get('signature'):
1767
+ skipped = True
1768
+ elif e['signature']['state'] == 'unavailable':
1769
+ missing.add(e['signature']['alg'])
1770
+ for s in e.get('stamps') or []:
1771
+ if s['state'] == 'unavailable':
1772
+ missing.add("the time-stamp service's method")
1773
+ elif kind in ('vouching', 'withdrawal', 'pass', 'acknowledgement'):
1774
+ if kind == 'vouching':
1775
+ counts['vouchings'] += 1
1776
+ if kind == 'pass':
1777
+ counts['passes'] += 1
1778
+ if kind == 'acknowledgement':
1779
+ counts['acknowledgements'] += 1
1780
+ note(e.get('signatures') or [])
1781
+ else:
1782
+ skipped = True
1783
+ for h in held:
1784
+ for s in h['stamps']:
1785
+ if s['state'] == 'unavailable':
1786
+ missing.add("the time-stamp service's method")
1787
+ for a in h['acknowledgements']:
1788
+ note(a['signatures'])
1789
+ problem_found = len(book_problems) > 0 or any(e['problems'] for e in entries)
1790
+ fully_checked = not skipped and len(missing) == 0
1791
+ first_breach = next((e for e in entries if e['breaches']), None)
1792
+ # "Within its slip" is said only if every stub, and every pass, was
1793
+ # compared with what it rests on, and none was found outside it.
1794
+ all_compared = all((e.get('compared') is True) if e['kind'] in ('stub', 'pass') else e['kind'] != 'unreadable' for e in entries)
1795
+ return {
1796
+ 'intact': not problem_found and fully_checked,
1797
+ 'problemFound': problem_found,
1798
+ 'fullyChecked': fully_checked,
1799
+ 'methodsMissing': _js.sort_strings(missing),
1800
+ 'withinSlips': first_breach is None and all_compared and not problem_found,
1801
+ 'firstBreach': first_breach['index'] if first_breach else None,
1802
+ 'counts': counts,
1803
+ # How far an outside time-stamp from a trusted service reaches, if any does.
1804
+ 'sealed': None if problem_found else sealed,
1805
+ 'limits': [*LIMITS[:-1], _stamped_words(sealed)] if sealed and not problem_found else list(LIMITS),
1806
+ }
1807
+
1808
+
1809
+ def _unused_disclosures(result, options):
1810
+ """Disclosures are handed over as {fingerprint of a slip: [disclosures]}.
1811
+ Anything else is refused, never set aside: the person handed it over to
1812
+ have fields revealed. Where no such slip is among the entries, the
1813
+ disclosures were not used, and the person is told."""
1814
+ if 'disclosures' not in options:
1815
+ return
1816
+ handed = options['disclosures']
1817
+ if not isinstance(handed, dict):
1818
+ raise f.fail('disclosures', 'must be laid out as {the fingerprint of a slip: [its disclosures]}.')
1819
+ slips = {e['fingerprint'] for e in result['entries'] if e['kind'] == 'slip'}
1820
+ if any(fp not in slips for fp in handed):
1821
+ result['notes'].append('Disclosures were handed over for a slip that is not here. They were not used.')
1822
+
1823
+
1824
+ def _check_held(result, reader, options, late, beside):
1825
+ """Cancellations that the person kept, handed over beside the book.
1826
+
1827
+ Whoever keeps a book can leave a cancellation out of it, or put a later
1828
+ time-stamp in the place of the person's own. The person's own copy, with
1829
+ its time-stamp, settles both: it is checked against the slip's passkey
1830
+ as a cancellation in the book is, and the earliest time a counted
1831
+ time-stamp gives it is used. A stub then counts as made before the
1832
+ cancellation only if an outside time-stamp shows that it existed by then.
1833
+
1834
+ A copy that does not pass its check is a problem, never dropped: the
1835
+ person handed it over to have it counted.
1836
+
1837
+ Nothing the reader keeps is changed: what the copies show about the
1838
+ stubs is gathered in "late", and the slips they cancel in "beside"."""
1839
+ slips, passes = reader.slips, reader.passes
1840
+ if 'cancellations' not in options:
1841
+ return
1842
+ handed = options['cancellations']
1843
+ without = _without(options)
1844
+ # Up to sixteen, none included: an empty list is what a stub writer that holds none hands back.
1845
+ f.list_of(handed, 0, MAX_HELD, 'cancellations')
1846
+ seen = set()
1847
+ # What the acknowledgements show is applied once every copy has been through the time-stamp rule.
1848
+ acknowledged_copies = []
1849
+ strict = _js.IdentityMap()
1850
+ # The problems with acknowledgements, by the place of their copy: reported, without setting the copy aside.
1851
+ acknowledgement_problems = []
1852
+ for i, value in enumerate(handed):
1853
+ held = {'kind': 'cancellation', 'fingerprint': None, 'slip': None, 'inBook': None, 'used': False, 'problems': [], 'notes': [], 'stamps': [], 'acknowledgements': []}
1854
+ result['held'].append(held)
1855
+ try:
1856
+ f.members(value, ['cancellation'], ['acknowledgements', 'stamps'], f'cancellations[{i}]')
1857
+ parsed = parse_record(value.get('cancellation'), 'cancellation')
1858
+ held['fingerprint'] = parsed.fingerprint
1859
+ validate_cancellation_content(parsed.content)
1860
+ c = parsed.content
1861
+ held['content'] = c
1862
+ held['slip'] = c['slip']
1863
+ if parsed.fingerprint in seen:
1864
+ raise f.fail(f'cancellations[{i}]', 'the same cancellation is handed over twice.')
1865
+ seen.add(parsed.fingerprint)
1866
+ slip = slips.get(c['slip'])
1867
+ if not slip or not slip['usable']:
1868
+ raise Refusal('slip-missing', 'The slip it names is not in the book, or did not pass its own check.')
1869
+ alg = slip['content']['issuer']['key']['alg']
1870
+ held['signature'] = {'method': f'passkey ({alg})', 'alg': alg, 'state': 'invalid'}
1871
+ try:
1872
+ held['signature']['state'] = check_passkey_signature(parsed, slip['content']['issuer'], without)
1873
+ except Exception:
1874
+ raise Refusal('cancellation-invalid', 'It was not signed with the passkey the slip names, or its passkey values do not check.') from None
1875
+ confirmed = held['signature']['state'] == 'valid' and slip['verified']
1876
+ time = _check_stamps(held, value['stamps'], parsed.fingerprint, nan_time(c['when']), options, confirmed) if 'stamps' in value else None
1877
+ if 'acknowledgements' in value:
1878
+ found = _check_held_acknowledgements(held, value['acknowledgements'], parsed.fingerprint, c['slip'], slip, passes, result['entries'], without, confirmed)
1879
+ for p in found:
1880
+ acknowledgement_problems.append((i, p))
1881
+ if found:
1882
+ held['notes'].append('An acknowledgement handed over with it did not pass its check, so that acknowledgement was not used. See the problems at the top.')
1883
+ if held['problems']:
1884
+ continue
1885
+ if held['signature']['state'] != 'valid' or not slip['verified']:
1886
+ held['notes'].append('This device could not confirm it, or the slip it names, so it was not used.')
1887
+ continue
1888
+ held['used'] = True
1889
+ # The check before acting allows nothing under a slip the person has cancelled.
1890
+ beside.add(c['slip'])
1891
+ in_book = next((e for e in result['entries'] if e['kind'] == 'cancellation' and e['fingerprint'] == parsed.fingerprint), None)
1892
+ held['inBook'] = in_book['index'] if in_book else None
1893
+ # What the agent's side acknowledged, where this device confirmed who signed.
1894
+ told = [a for a in held['acknowledgements'] if a['state'] == 'valid']
1895
+ if len(held['acknowledgements']) > len(told):
1896
+ held['notes'].append('This device could not check an acknowledgement handed over with it, so that acknowledgement was not used.')
1897
+ if not in_book and told:
1898
+ first = _js.sort_strings([a['when'] for a in told])[0]
1899
+ held['notes'].append(
1900
+ f"The book does not hold this cancellation, though the agent's side acknowledged at {first} (its own word for the time) that it was handed it. "
1901
+ 'So it was left out of the book, or was still to be written into it when this copy of the book was made.'
1902
+ )
1903
+ elif not in_book:
1904
+ held['notes'].append('The book does not hold this cancellation: whoever keeps the book left it out, or was never given it.')
1905
+ elif in_book['problems']:
1906
+ held['notes'].append(f"The book holds this cancellation at entry {in_book['index']}, but that entry did not pass its check.")
1907
+ for a in told:
1908
+ acknowledged_copies.append((slip, c['slip'], a))
1909
+ if time is None:
1910
+ held['notes'].append(
1911
+ "It has no time-stamp from a service you named as trusted, so it cannot be placed in time. A stub is reported because of it only where the stub is dated after the agent's side acknowledged it."
1912
+ if told
1913
+ else 'It has no time-stamp from a service you named as trusted, so it cannot be placed in time, and no stub is marked because of it.'
1914
+ )
1915
+ continue
1916
+ made = _cancelled_at(held, nan_time(c['when']))
1917
+ held['stampedAt'] = format_time(made)
1918
+ _note_late_stamp(held, made, c['when'])
1919
+ _note_block_set_aside(held, nan_time(c['when']), _CANCELLATION_BLOCK_SET_ASIDE)
1920
+ _mark_not_shown_earlier(
1921
+ slip,
1922
+ made,
1923
+ "This stub is not shown to have existed before the person cancelled the slip: no outside time-stamp from before the cancellation covers it. "
1924
+ "The person's own copy of the cancellation was handed over beside the book.",
1925
+ late,
1926
+ strict,
1927
+ )
1928
+ except Exception as e:
1929
+ held['problems'].append(problem_from(e))
1930
+ added = _js.IdentityMap()
1931
+ # First what each acknowledging agent did itself after it was told, for every acknowledgement; then what was
1932
+ # done under a pass handed on late. So the words a stub is given do not depend on the order of the acknowledgements.
1933
+ for under_passes in (False, True):
1934
+ for slip, slip_fingerprint, a in acknowledged_copies:
1935
+ _mark_after_acknowledged(slip, slip_fingerprint, passes, result['entries'], a, late, strict, added, under_passes)
1936
+ for i, held in enumerate(result['held']):
1937
+ for p in held['problems']:
1938
+ result['problems'].append({'code': p['code'], 'message': f"Cancellation {i + 1} of those handed over beside the book did not pass its check, so it was not used. {p['message']}"})
1939
+ for i, p in acknowledgement_problems:
1940
+ result['problems'].append({
1941
+ 'code': p['code'],
1942
+ 'message': f'An acknowledgement handed over with cancellation {i + 1} of those beside the book did not pass its check, so it was not used. '
1943
+ f"The cancellation itself was not set aside because of it. {p['message']}",
1944
+ })
1945
+
1946
+
1947
+ _BLOCK = re.compile(r'[0-9a-fA-F]{64}')
1948
+
1949
+
1950
+ def _check_blocks_option(options):
1951
+ """The blocks a person trusts are named by their fingerprints: 64 hex
1952
+ characters each. Anything else is refused, never set aside."""
1953
+ if 'blocks' not in options:
1954
+ return
1955
+ blocks = options['blocks']
1956
+ sound = isinstance(blocks, list) and len(blocks) <= MAX_ENTRIES and all(isinstance(b, str) and _BLOCK.fullmatch(b) for b in blocks)
1957
+ if not sound:
1958
+ raise f.fail('blocks', 'must be a list of block fingerprints, each 64 hex characters, at most 100,000.')
1959
+
1960
+
1961
+ def _compare_trusted(result, options):
1962
+ """Compare the top fingerprint and the number of entries with what the
1963
+ checker already trusts, if it was given either."""
1964
+ if 'expectedRoot' in options and not _js.strict_equal(options['expectedRoot'], result['root']):
1965
+ raise Refusal('root-mismatch', 'The top fingerprint is not the one expected.')
1966
+ if 'expectedSize' in options and not _js.strict_equal(options['expectedSize'], result['size']):
1967
+ raise Refusal('root-mismatch', 'The number of entries is not the one expected.')
1968
+
1969
+
1970
+ def check_book(text, options=None, **named):
1971
+ """Check a whole book.
1972
+
1973
+ The answer always has the same shape, whatever the book holds. It never
1974
+ raises for a bad record: a failed check is a normal answer. The options
1975
+ are those of the JavaScript library, by its names or in Python's way:
1976
+ issuer_keys, expected_root, expected_size, stamp_services, blocks,
1977
+ seal_keys, vouchers, disclosures, cancellations, without_methods. See
1978
+ the README, "What the checker returns". The content of an entry that
1979
+ has problems, or whose "verified" is "none", is given as it was read,
1980
+ for whoever must find out what went wrong. It is unverified: never show
1981
+ it as fact."""
1982
+ return check_book_keeping_state(text, fixed_options(options, **named))['result']
1983
+
1984
+
1985
+ def check_book_keeping_state(text, options=None):
1986
+ """Check a whole book, and also hand back the running state of every slip,
1987
+ for the check before acting (guard.py). Not part of the public interface."""
1988
+ carried = start_carried(fixed_options(options))
1989
+ carry_on(carried, text)
1990
+ return answer_of(carried)
1991
+
1992
+
1993
+ # --- a book that is read once and then carried on ---
1994
+ #
1995
+ # A whole check is: start, read every line, give the answer. The three
1996
+ # steps are kept apart here, so that more lines can be read after an
1997
+ # answer was given, without reading the earlier ones again. check_book does
1998
+ # the three in one go; open_checker and the stub writer carry a book on.
1999
+ # There is one way of reading a line (_read_line), so a book that is
2000
+ # carried on gets the answer that a whole check gives.
2001
+
2002
+
2003
+ class _Carried:
2004
+ __slots__ = ('options', 'reader', 'any', 'closed', 'failure')
2005
+
2006
+
2007
+ def start_carried(options=None):
2008
+ """Start reading a book. Not part of the public interface."""
2009
+ if not isinstance(options, Mapping):
2010
+ options = _Fixed()
2011
+ carried = _Carried()
2012
+ carried.options = options
2013
+ carried.reader = _Reader(options, True)
2014
+ # Whether any text was handed over at all, and whether it ended with a line feed.
2015
+ carried.any = False
2016
+ carried.closed = True
2017
+ # A problem with the book as a whole, which no later line can mend: an option that cannot be used, or too many entries.
2018
+ carried.failure = None
2019
+ try:
2020
+ _check_blocks_option(options)
2021
+ except Exception as e:
2022
+ carried.failure = problem_from(e)
2023
+ return carried
2024
+
2025
+
2026
+ def carry_on(carried, text):
2027
+ """Read more of a book: whole lines, each ending with a line feed. (The
2028
+ last line of all may lack its line feed, as in a whole check; nothing
2029
+ can then be added after it.) Not part of the public interface."""
2030
+ if not isinstance(text, str) or len(text) == 0:
2031
+ return
2032
+ if carried.any and not carried.closed:
2033
+ raise ValueError('The book so far does not end with a line feed, so nothing can be added to it.')
2034
+ carried.any = True
2035
+ carried.closed = text.endswith('\n')
2036
+ if carried.failure:
2037
+ return
2038
+ reader = carried.reader
2039
+ try:
2040
+ lines = _split_lines(text)
2041
+ if len(reader.entries) + len(lines) > MAX_ENTRIES:
2042
+ raise Refusal('too-large', 'The book holds more than 100,000 entries.')
2043
+ except Exception as e:
2044
+ carried.failure = problem_from(e)
2045
+ return
2046
+ for line in lines:
2047
+ _read_line(reader, len(reader.entries), line)
2048
+
2049
+
2050
+ def answer_of(carried, options=None):
2051
+ """The answer for a book as it stands. It changes nothing that the reader
2052
+ keeps, so more lines can be read afterwards, and the answer asked for
2053
+ again. Not part of the public interface.
2054
+
2055
+ options: the options for what is handed over beside the book
2056
+ (cancellations, disclosures) and for what is expected of it
2057
+ (expectedRoot, expectedSize); the options the lines were read with by
2058
+ default. Returns {"result", "state", "beside"}: the answer; the running
2059
+ state of every slip, for the check before acting; and the slips that a
2060
+ cancellation handed over beside the book cancels."""
2061
+ if options is None:
2062
+ options = carried.options
2063
+ result = {'format': 'provared-book-draft-v0', 'size': 0, 'root': None, 'problems': [], 'notes': [], 'entries': [], 'held': [], 'summary': None}
2064
+ state = {'slips': {}, 'terms': {}, 'passes': {}, 'ids': _Ids()}
2065
+ sealed = None
2066
+ late = _js.IdentityMap()
2067
+ beside = set()
2068
+ if not isinstance(options, Mapping):
2069
+ options = _Fixed()
2070
+ try:
2071
+ if not carried.any:
2072
+ raise Refusal('not-json', 'There is no record to check.')
2073
+ if carried.failure:
2074
+ raise Refusal(carried.failure['code'], carried.failure['message'])
2075
+ reader = carried.reader
2076
+ result['size'] = len(reader.entries)
2077
+ # Until the answer is put together, the entries are looked at as the reader keeps them.
2078
+ result['entries'] = reader.entries
2079
+ late = _settle_late(reader)
2080
+ state = {'slips': reader.slips, 'terms': reader.terms, 'passes': reader.passes, 'ids': reader.ids}
2081
+ sealed = reader.seals['sealed']
2082
+ result['root'] = to_base64url(reader.tree.root())
2083
+ result['problems'].extend(reader.handed_problems)
2084
+ _unused_disclosures(result, options)
2085
+ _check_held(result, reader, options, late, beside)
2086
+ _compare_trusted(result, options)
2087
+ except Exception as e:
2088
+ result['problems'].append(problem_from(e))
2089
+ read = result['entries']
2090
+ result['entries'] = [_answered(e, late, read) for e in read]
2091
+ result['summary'] = _summarise(result['entries'], result['problems'], sealed, result['held'])
2092
+ return {'result': result, 'state': state, 'beside': beside}
2093
+
2094
+
2095
+ def fork_carried(carried, whole=False):
2096
+ """A book that carries on by itself from where this one stands, to try one
2097
+ more line: a stub or, with "whole", a line of any kind (_fork_reader). The
2098
+ book it was split from is left exactly as it was. Not part of the public
2099
+ interface."""
2100
+ out = _Carried()
2101
+ out.options = carried.options
2102
+ out.reader = _fork_reader(carried.reader, whole)
2103
+ out.any = carried.any
2104
+ out.closed = carried.closed
2105
+ out.failure = carried.failure
2106
+ return out
2107
+
2108
+
2109
+ def promote_carried(carried):
2110
+ """A book that was split off to try a line takes the place of the one it
2111
+ was split from, which is not used again. From here on it reads any kind
2112
+ of entry. Not part of the public interface."""
2113
+ carried.reader.forked = False
2114
+ return carried
2115
+
2116
+
2117
+ class Checker:
2118
+ """A checker that can be carried on. It reads a book once, keeps what it
2119
+ has worked out, and then takes more lines as the book grows, without
2120
+ reading the earlier lines again. Its answer for the book so far is the
2121
+ answer that check_book gives for the same text."""
2122
+
2123
+ def __init__(self, text='', options=None, **named):
2124
+ self._carried = start_carried(fixed_options(options, **named))
2125
+ carry_on(self._carried, text)
2126
+
2127
+ def add(self, more):
2128
+ """Take more whole lines, each ending with a line feed."""
2129
+ carry_on(self._carried, more)
2130
+
2131
+ def result(self):
2132
+ """The answer for the book so far: a copy of its own each time, so that
2133
+ nothing done to an answer changes the checker."""
2134
+ return copy.deepcopy(answer_of(self._carried)['result'])
2135
+
2136
+
2137
+ def open_checker(text='', options=None, **named):
2138
+ """A checker that can be carried on (see Checker). The options are read
2139
+ once, when the checker is opened, as check_book reads them, and fixed."""
2140
+ return Checker(text, options, **named)
2141
+
2142
+
2143
+ def _disclosures_for(disclosures, index):
2144
+ """The disclosures handed over for one page, found as JavaScript finds a
2145
+ member by number: in an object by the number written as text, or in a
2146
+ list by its place."""
2147
+ if isinstance(disclosures, Mapping):
2148
+ for key in (index, str(index)):
2149
+ if key in disclosures:
2150
+ return disclosures[key]
2151
+ return None
2152
+ if isinstance(disclosures, list) and isinstance(index, int) and 0 <= index < len(disclosures):
2153
+ return disclosures[index]
2154
+ return None
2155
+
2156
+
2157
+ def make_show(text, indexes, seal=None, disclosures=None):
2158
+ """Make a Show: some entries of a book, each with the proof that it is in the
2159
+ book (format description, section 11).
2160
+
2161
+ indexes: which entries, counting from 0; at most 64. seal: the entry that
2162
+ is a seal to show the pages under: the Show is then of the book as it
2163
+ stood when that seal was made, and carries the seal and its time-stamps.
2164
+ disclosures: for covered fields, the disclosures to hand over with a
2165
+ page, by the page's index."""
2166
+ lines = _split_lines(text)
2167
+ seal_entry = None
2168
+ seal = _js.whole(seal)
2169
+ if seal is not None:
2170
+ if not isinstance(seal, int) or isinstance(seal, bool) or seal < 1 or seal >= len(lines):
2171
+ raise ValueError('there is no such entry')
2172
+ seal_entry = _js.parse(lines[seal])
2173
+ if not isinstance(seal_entry, dict) or 'seal' not in seal_entry:
2174
+ raise ValueError('that entry is not a seal')
2175
+ lines = lines[:seal]
2176
+ leaves = [utf8(line) for line in lines]
2177
+ wanted = sorted({_js.whole(i) for i in indexes})
2178
+ if len(wanted) < 1 or len(wanted) > MAX_PAGES:
2179
+ raise ValueError('a Show holds 1 to 64 pages')
2180
+ pages = []
2181
+ for index in wanted:
2182
+ path = inclusion_path(leaves, index)
2183
+ page = {'index': index, 'entry': lines[index], 'path': [to_base64url(p) for p in path]}
2184
+ reveal = _disclosures_for(disclosures, index)
2185
+ if _js.truthy(reveal):
2186
+ page['disclosures'] = reveal
2187
+ pages.append(page)
2188
+ show = {'type': 'provared.show.v0', 'size': len(lines), 'root': to_base64url(tree_root(leaves)), 'pages': pages}
2189
+ if seal_entry is not None:
2190
+ show['seal'] = seal_entry
2191
+ return show
2192
+
2193
+
2194
+ def check_show(show, options=None, **named):
2195
+ """Check a Show.
2196
+
2197
+ A Show can show a stub outside its slip (a wrong action, a wrong party, a
2198
+ wrong time, or a single stub over the limit). It cannot show that the
2199
+ agent stayed within a limit: that needs every stub under the slip.
2200
+
2201
+ show: the Show, as a dict or as JSON text. Returns the same shape as
2202
+ check_book returns. A seal that comes with the Show is the last entry."""
2203
+ result = {'format': 'provared-show-draft-v0', 'size': 0, 'root': None, 'problems': [], 'notes': [], 'entries': [], 'held': [], 'summary': None}
2204
+ sealed = None
2205
+ options = fixed_options(options, **named)
2206
+ try:
2207
+ if isinstance(show, str):
2208
+ try:
2209
+ show = _js.parse(show)
2210
+ except ValueError:
2211
+ raise Refusal('not-json', 'The Show is not JSON.') from None
2212
+ _check_blocks_option(options)
2213
+ f.members(show, ['pages', 'root', 'size', 'type'], ['seal'], 'show')
2214
+ if show['type'] != 'provared.show.v0' or not isinstance(show['type'], str):
2215
+ raise f.fail('show.type', 'must be provared.show.v0.')
2216
+ f.whole_number(show['size'], 'show.size')
2217
+ if show['size'] < 1 or show['size'] > MAX_ENTRIES:
2218
+ raise f.fail('show.size', 'must be from 1 to 100,000.')
2219
+ f.fingerprint_text(show['root'], 'show.root')
2220
+ f.list_of(show['pages'], 1, MAX_PAGES, 'show.pages')
2221
+ result['size'] = show['size']
2222
+ result['root'] = show['root']
2223
+ root = from_base64url(show['root'])
2224
+
2225
+ _compare_trusted(result, options)
2226
+
2227
+ lines = []
2228
+ seen = set()
2229
+ for i, page in enumerate(show['pages']):
2230
+ f.members(page, ['entry', 'index', 'path'], ['disclosures'], f'pages[{i}]')
2231
+ if 'disclosures' in page:
2232
+ f.list_of(page['disclosures'], 1, 64, f'pages[{i}].disclosures')
2233
+ f.whole_number(page['index'], f'pages[{i}].index')
2234
+ if not isinstance(page['entry'], str) or _js.utf16_length(page['entry']) < 1 or _js.utf16_length(page['entry']) > MAX_LINE_BYTES:
2235
+ raise f.fail(f'pages[{i}].entry', 'must be one line of a book.')
2236
+ f.list_of(page['path'], 0, 64, f'pages[{i}].path')
2237
+ if page['index'] in seen:
2238
+ raise f.fail(f'pages[{i}].index', 'a page is repeated.')
2239
+ seen.add(page['index'])
2240
+ path = []
2241
+ for j, p in enumerate(page['path']):
2242
+ f.fingerprint_text(p, f'pages[{i}].path[{j}]')
2243
+ path.append(from_base64url(p))
2244
+ if not verify_inclusion(page['index'], show['size'], utf8(page['entry']), path, root):
2245
+ raise Refusal('proof-invalid', f"The proof for page {_js.number_text(page['index'])} does not lead to the top fingerprint.")
2246
+ lines.append((page['index'], page['entry'], page['disclosures'] if 'disclosures' in page else _UNDEFINED))
2247
+ lines.sort(key=lambda item: item[0])
2248
+ reader = _Reader(options, False)
2249
+ for index, line, disclosures in lines:
2250
+ _read_line(reader, index, line, disclosures)
2251
+ checked_ids = reader.ids
2252
+ stamp_floor = reader.seals['lastStampTime']
2253
+ result['entries'] = reader.entries
2254
+ result['problems'].extend(reader.handed_problems)
2255
+ _unused_disclosures(result, options)
2256
+ _compare_page_seals(result['entries'])
2257
+
2258
+ # A seal that comes with the Show: the recorder's signature on exactly
2259
+ # this top fingerprint and this number of entries, and the outside
2260
+ # time-stamps on that seal.
2261
+ if 'seal' in show:
2262
+ entry = _new_entry(show['size'], 'seal')
2263
+ result['entries'].append(entry)
2264
+ try:
2265
+ f.members(show['seal'], ['seal'], ['stamps'], 'show.seal')
2266
+ # It is held to the pages as a seal in a whole book is held to the
2267
+ # entries before it: no unique number twice, and no block
2268
+ # time-stamp counted that states a time before a service's
2269
+ # time-stamp on a seal among the pages.
2270
+ fresh = {'count': 0, 'last': None, 'lastWhen': -INF, 'lastStampTime': stamp_floor, 'next': 0, 'sealed': None}
2271
+ _check_seal_entry(entry, show['seal'], fresh, checked_ids, options, None, [])
2272
+ entry['vouched'] = None
2273
+ if entry['content']['size'] != show['size'] or entry['content']['root'] != show['root']:
2274
+ entry['problems'].append({'code': 'seal-mismatch', 'message': 'The seal that comes with the Show is for another book, or for this book at another length.'})
2275
+ # Against the seals among the pages, which all come before it in the book.
2276
+ _later_than_seals(entry, result['entries'])
2277
+ # The seal's own date against the pages it covers, as in a whole book.
2278
+ if _dated_before_covered(
2279
+ nan_time(entry['content']['when']),
2280
+ result['entries'],
2281
+ len(result['entries']) - 1,
2282
+ nan_time(entry['stampedAt']) if entry.get('stampedAt') else None,
2283
+ ):
2284
+ entry['problems'].append({'code': 'time-went-backwards', 'message': 'This seal is dated before a page it covers. One of the two dates is not as it says.'})
2285
+ except Exception as e:
2286
+ entry['problems'].append(problem_from(e))
2287
+ if entry['problems']:
2288
+ entry['verified'] = 'none'
2289
+ elif entry.get('stampedAt'):
2290
+ time = nan_time(entry['stampedAt'])
2291
+ for page in result['entries']:
2292
+ if page is not entry:
2293
+ _credit_stamp(page, entry['stampedAt'], time)
2294
+ sealed = {'entries': show['size'], 'when': entry['stampedAt'], 'seal': show['size'], 'by': _counted_by(entry, stamp_floor)}
2295
+ if 'expectedRoot' not in options and not sealed:
2296
+ result['notes'].append('The top fingerprint was not compared with a copy you already trust, and no seal with a time-stamp from a service you trust comes with it.')
2297
+ elif 'expectedSize' not in options and not sealed:
2298
+ result['notes'].append('The number of entries was not compared with a number you already trust. The top fingerprint alone does not fix it.')
2299
+ result['notes'].append('Single pages cannot show whether the chain is whole, whether a limit was kept, or whether an approval was used only once. Those need every stub under the slip.')
2300
+ result['notes'].append('Single pages cannot show that a slip was not cancelled, or that a vouching record was not withdrawn, on a page that is not shown.')
2301
+ # An empty list is none, as for a whole book.
2302
+ if 'cancellations' in options and not (isinstance(options['cancellations'], list) and len(options['cancellations']) == 0):
2303
+ result['notes'].append(
2304
+ 'Cancellations were handed over beside this Show. They were not used: single pages cannot show which stubs existed before a cancellation. Check the whole book with them.'
2305
+ )
2306
+ except Exception as e:
2307
+ result['problems'].append(problem_from(e))
2308
+ # An acknowledgement whose cancellation, among the pages, failed after the acknowledgement was read.
2309
+ for e in result['entries']:
2310
+ if e['kind'] != 'acknowledgement' or 'cancellation' not in e:
2311
+ continue
2312
+ named_entry = next((p for p in result['entries'] if p['index'] == e['cancellation']), None)
2313
+ if named_entry and named_entry['problems']:
2314
+ _fail_acknowledgement(e)
2315
+ # A seal or a cancellation that did not pass its check shows no time of its own.
2316
+ for e in result['entries']:
2317
+ if e['problems']:
2318
+ e.pop('stampedAt', None)
2319
+ result['summary'] = _summarise(result['entries'], result['problems'], sealed)
2320
+ return result