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/headers.py ADDED
@@ -0,0 +1,507 @@
1
+ # A chain of block headers, checked on this device: the means to know,
2
+ # without asking anyone, that a block which a block time-stamp leads to is
3
+ # part of the Bitcoin blockchain.
4
+ #
5
+ # Nothing here makes a request. The headers come from a file the person
6
+ # has; the command provared-headers of the JavaScript library fetches them
7
+ # from Bitcoin nodes the person names, and nothing else in this library
8
+ # does.
9
+ #
10
+ # Each header is checked against the one before it, by the rules every
11
+ # Bitcoin node applies to headers: it names the block before it; its
12
+ # double SHA-256 meets the target written in it; that target follows the
13
+ # difficulty rule (unchanged within a run of 2,016 blocks, recomputed at
14
+ # the start of each run from the time the run before it took); its time is
15
+ # later than the middle of the eleven before it, and not more than two
16
+ # hours ahead of this device's clock. The first header must be the
17
+ # chain's own first block, and the chain must hold the blocks listed
18
+ # below at their places. Those known blocks are what stops a chain that
19
+ # branches off early and keeps its difficulty low: such a chain would have
20
+ # to hold each of them, which no one can make without the real chain's
21
+ # work.
22
+
23
+ import hashlib
24
+ import math
25
+ import re
26
+ import struct
27
+ import types
28
+
29
+ from .encoding import Refusal, format_time, now_ms
30
+
31
+ HEADER_BYTES = 80
32
+ """The bytes of one block header."""
33
+
34
+ MIN_BLOCKS_AFTER = 6
35
+ """A block counts as part of the chain only with at least this many blocks after it."""
36
+
37
+ MAX_HEADERS = 2_000_000
38
+ """The most headers a chain file may hold: room for the chain to about the year 2045."""
39
+
40
+
41
+ def _double_sha256(data):
42
+ return hashlib.sha256(hashlib.sha256(bytes(data)).digest()).digest()
43
+
44
+
45
+ def _written(hash_bytes):
46
+ """A block's fingerprint as it is written everywhere: the hash's bytes in reverse order, in hex."""
47
+ return bytes(hash_bytes)[::-1].hex()
48
+
49
+
50
+ def _from_hex(text):
51
+ # As the JavaScript library reads hex: pair by pair, a last single character left out.
52
+ return bytes(int(text[i:i + 2], 16) for i in range(0, len(text) - 1, 2))
53
+
54
+
55
+ BITCOIN = types.MappingProxyType(
56
+ {
57
+ 'first': '0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c',
58
+ # The easiest target a block may have.
59
+ 'limit': (1 << 224) - 1,
60
+ # The difficulty is recomputed every 2,016 blocks, aiming at two weeks for each run.
61
+ 'interval': 2016,
62
+ 'timespan': 14 * 24 * 60 * 60,
63
+ 'retarget': True,
64
+ # From these blocks on, a header's version must be at least this (the
65
+ # upgrades of 2013 and 2015 that every node enforces).
66
+ 'versions': ((227931, 2), (363725, 3), (388381, 4)),
67
+ # After the last known block, no target may be more than four times
68
+ # easier than that block's. Every change of difficulty in the real chain
69
+ # so far eased it by less than one and a half times; a branch made after
70
+ # the last known block, with dates pushed forward to ease it at every
71
+ # change, is held to work no less than a quarter of the real chain's then.
72
+ 'ease': 4,
73
+ 'known': (
74
+ (100000, '000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506'),
75
+ (200000, '000000000000034a7dedef4a161fa058a2d67a173a90155f3a2fe6fc132e0ebf'),
76
+ (300000, '000000000000000082ccf8f1557c5d40b21edabb18d2d691cfbf87118bac7254'),
77
+ (400000, '000000000000000004ec466ce4732fe6f1ed1cddc2ed4b328fff5224276e3f6f'),
78
+ (500000, '00000000000000000024fb37364cbf81fd49cc2d51c09c75c35433c3a1945d04'),
79
+ (600000, '00000000000000000007316856900e76b4f7a9139cfbfba89842c8d196cd5f91'),
80
+ (700000, '0000000000000000000590fc0f3eba193a278534220b2b37e9849e1a770ca959'),
81
+ (800000, '00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054'),
82
+ (900000, '000000000000000000010538edbfd2d5b809a33dd83f284aeea41c6d0d96968a'),
83
+ (969696, '000000000000000000001e39df127cbab82a824ac43cfcdbf62e59f6a08b9f0b'),
84
+ ),
85
+ }
86
+ )
87
+ """The rules of the Bitcoin chain that headers are checked by. The known
88
+ blocks were read on 5 October 2026 from the whole chain of headers, which
89
+ was checked by these rules from the first block on the author's computer,
90
+ and which a second node on another network agreed with.
91
+
92
+ Rules of another chain are given in the same form: "first" (the first
93
+ header in hex), "limit", "interval", "timespan", "retarget", "known" (a
94
+ list of [number, fingerprint]) and, where they apply, "versions" and
95
+ "ease"."""
96
+
97
+
98
+ def _invalid(message):
99
+ return Refusal('headers-invalid', message)
100
+
101
+
102
+ # --- JavaScript's arithmetic on numbers, where the JavaScript library relies on it ---
103
+
104
+
105
+ def _uint32(value):
106
+ """ToUint32: what JavaScript's bit operators make of a number."""
107
+ if isinstance(value, float):
108
+ if not math.isfinite(value):
109
+ return 0
110
+ value = math.trunc(value)
111
+ return int(value) & 0xFFFFFFFF
112
+
113
+
114
+ def _int32(value):
115
+ """ToInt32"""
116
+ v = _uint32(value)
117
+ return v - (1 << 32) if v & 0x80000000 else v
118
+
119
+
120
+ def _big(value):
121
+ """BigInt(value) for a number: refused unless it is a whole number."""
122
+ if isinstance(value, bool):
123
+ return int(value)
124
+ if isinstance(value, int):
125
+ return value
126
+ if isinstance(value, float) and math.isfinite(value) and value == math.floor(value):
127
+ return int(value)
128
+ raise ValueError('The number cannot be converted to a BigInt because it is not an integer')
129
+
130
+
131
+ def _big_divide(a, b):
132
+ """Division of BigInts: the quotient, cut towards zero."""
133
+ if b == 0:
134
+ raise ZeroDivisionError('Division by zero')
135
+ q = abs(a) // abs(b)
136
+ return q if (a < 0) == (b < 0) else -q
137
+
138
+
139
+ def _number(value):
140
+ """A value read as a JavaScript number, for adding and comparing."""
141
+ return float(value) if not isinstance(value, float) else value
142
+
143
+
144
+ def _js_max(a, b):
145
+ if a != a or b != b:
146
+ return math.nan
147
+ return a if a >= b else b
148
+
149
+
150
+ def _js_min(a, b):
151
+ if a != a or b != b:
152
+ return math.nan
153
+ return a if a <= b else b
154
+
155
+
156
+ def _target_of(bits):
157
+ """The target that a compact "bits" value stands for."""
158
+ b = _uint32(bits)
159
+ if b & 0x00800000:
160
+ return -1 # a negative target: no hash meets it
161
+ exponent = b >> 24
162
+ mantissa = b & 0x007FFFFF
163
+ return mantissa >> (8 * (3 - exponent)) if exponent <= 3 else mantissa << (8 * (exponent - 3))
164
+
165
+
166
+ def _compact_of(target):
167
+ """The compact "bits" value of a target, as Bitcoin writes it."""
168
+ written = format(target, 'x') if target >= 0 else '-' + format(-target, 'x')
169
+ size = math.ceil(len(written) / 2)
170
+ c = target << (8 * (3 - size)) if size <= 3 else target >> (8 * (size - 3))
171
+ if _int32(c) & 0x00800000:
172
+ c = _uint32(c) >> 8
173
+ size += 1
174
+ return (c | (size << 24)) & 0xFFFFFFFF
175
+
176
+
177
+ def next_bits(bits, first_time, last_time, rules=BITCOIN):
178
+ """The "bits" at the start of a run: the target of the last block, scaled by
179
+ how long the run took against how long it should, by at most four times
180
+ either way, and never easier than the limit. Not part of the public
181
+ interface.
182
+
183
+ bits: the last block's bits. first_time: the time of the run's first
184
+ block, in seconds. last_time: the time of the run's last block, in seconds.
185
+ """
186
+ timespan = rules['timespan']
187
+ span = _js_min(_js_max(_number(last_time) - _number(first_time), timespan / 4), timespan * 4)
188
+ if span != span or not math.isfinite(span):
189
+ raise ValueError('The number cannot be converted to a BigInt because it is not an integer')
190
+ target = _big_divide(_target_of(bits) * math.floor(span), _big(timespan))
191
+ if target > rules['limit']:
192
+ target = rules['limit']
193
+ return _compact_of(target)
194
+
195
+
196
+ def _subarray(buffer, start, end):
197
+ """TypedArray.prototype.subarray, with its rules for places before the start."""
198
+ n = len(buffer)
199
+ start = max(n + start, 0) if start < 0 else min(start, n)
200
+ end = max(n + end, 0) if end < 0 else min(end, n)
201
+ return bytes(buffer[start:max(start, end)])
202
+
203
+
204
+ class HeaderChain:
205
+ """A chain of headers being read, one header at a time. Not part of the
206
+ public interface: check_header_chain reads a whole file with it, and the
207
+ command that fetches headers adds what it fetches."""
208
+
209
+ def __init__(self, rules=BITCOIN, web_crypto=False):
210
+ self.rules = rules
211
+ self._web_crypto = web_crypto
212
+ self._room = 1024
213
+ self._headers = bytearray(self._room * HEADER_BYTES)
214
+ self._hashes = bytearray(self._room * 32)
215
+ self._count = 0
216
+ self._known = {height: fingerprint for height, fingerprint in rules['known']}
217
+ # The target of the bits last seen, as 32 bytes, most significant first,
218
+ # so that a hash is compared with it byte by byte.
219
+ self._target_bits = -1
220
+ self._target_bytes = None
221
+ # The last known block, and its target once the chain has reached it.
222
+ known = rules['known']
223
+ self._last_known = known[-1][0] if len(known) else -1
224
+ self._floor = None
225
+ versions = rules.get('versions')
226
+ self._versions = [] if versions is None else versions
227
+
228
+ # --- what is held ---
229
+
230
+ def _view_uint32(self, offset):
231
+ if offset < 0 or offset + 4 > len(self._headers):
232
+ raise IndexError('Offset is outside the bounds of the DataView')
233
+ return struct.unpack_from('<I', self._headers, offset)[0]
234
+
235
+ def _time_at(self, h):
236
+ return self._view_uint32(h * HEADER_BYTES + 68)
237
+
238
+ def _bits_at(self, h):
239
+ return self._view_uint32(h * HEADER_BYTES + 72)
240
+
241
+ def _hash_at(self, h):
242
+ return _subarray(self._hashes, h * 32, h * 32 + 32)
243
+
244
+ def _meets(self, hash_bytes, bits):
245
+ if bits != self._target_bits:
246
+ target = _target_of(bits)
247
+ self._target_bits = bits
248
+ self._target_bytes = None if target <= 0 or target > self.rules['limit'] else _from_hex(format(target, 'x').rjust(64, '0'))
249
+ if self._target_bytes is None:
250
+ return False
251
+ # The hash is a number written least significant byte first.
252
+ for i in range(32):
253
+ a = hash_bytes[31 - i]
254
+ b = self._target_bytes[i]
255
+ if a != b:
256
+ return a < b
257
+ return True
258
+
259
+ def _grow(self):
260
+ self._room *= 2
261
+ more_headers = bytearray(self._room * HEADER_BYTES)
262
+ more_headers[:self._count * HEADER_BYTES] = self._headers[:self._count * HEADER_BYTES]
263
+ more_hashes = bytearray(self._room * 32)
264
+ more_hashes[:self._count * 32] = self._hashes[:self._count * 32]
265
+ self._headers = more_headers
266
+ self._hashes = more_hashes
267
+
268
+ @property
269
+ def height(self):
270
+ """The number of the latest block held; -1 before the first."""
271
+ return self._count - 1
272
+
273
+ def add(self, header, now):
274
+ """Check one header as the next of the chain, and add it.
275
+
276
+ header: 80 bytes. now: this device's clock, in milliseconds.
277
+ """
278
+ h = self._count
279
+ rules = self.rules
280
+ if not isinstance(header, (bytes, bytearray)) or len(header) != HEADER_BYTES:
281
+ raise _invalid(f'Block {h}: a header must be 80 bytes.')
282
+ if h >= MAX_HEADERS:
283
+ raise _invalid(f'The chain holds more than {MAX_HEADERS} headers.')
284
+ header = bytes(header)
285
+ hash_bytes = _double_sha256(header)
286
+ if h == 0:
287
+ if header.hex() != rules['first']:
288
+ raise _invalid("Block 0: this is not the chain's first block.")
289
+ else:
290
+ previous = self._hash_at(h - 1)
291
+ for i in range(32):
292
+ if header[4 + i] != previous[i]:
293
+ raise _invalid(f'Block {h}: it does not name the block before it.')
294
+ bits = struct.unpack_from('<I', header, 72)[0]
295
+ if rules['retarget'] and h % rules['interval'] == 0:
296
+ expected = next_bits(self._bits_at(h - 1), self._time_at(h - rules['interval']), self._time_at(h - 1), rules)
297
+ else:
298
+ expected = self._bits_at(h - 1)
299
+ if bits != expected:
300
+ raise _invalid(f'Block {h}: its difficulty does not follow the rule.')
301
+ if not self._meets(hash_bytes, bits):
302
+ raise _invalid(f'Block {h}: its hash does not meet its target, so it does not carry the work it claims.')
303
+ ease = rules.get('ease')
304
+ if self._floor is not None and h > self._last_known and _target_of(bits) > self._floor * (4 if ease is None else ease):
305
+ raise _invalid(f'Block {h}: its target is more than four times easier than that of block {self._last_known}, the last block known to be part of the chain.')
306
+ version = struct.unpack_from('<i', header, 0)[0]
307
+ for start, least in self._versions:
308
+ if h >= start and version < least:
309
+ raise _invalid(f'Block {h}: its version is lower than every block from {start} on must have.')
310
+ time = struct.unpack_from('<I', header, 68)[0]
311
+ before = sorted(self._time_at(k) for k in range(max(0, h - 11), h))
312
+ if time <= before[len(before) >> 1]:
313
+ raise _invalid(f'Block {h}: its time is not later than the middle of the eleven blocks before it.')
314
+ if time * 1000 > now + 7200 * 1000:
315
+ raise _invalid(f"Block {h}: its time is more than two hours ahead of this device's clock.")
316
+ if h in self._known and _written(hash_bytes) != self._known[h]:
317
+ raise _invalid(f'Block {h}: it is not the block known to be at that place in the chain.')
318
+ if h == self._last_known:
319
+ self._floor = _target_of(struct.unpack_from('<I', header, 72)[0])
320
+ if h >= self._room:
321
+ self._grow()
322
+ if (h + 1) * HEADER_BYTES > len(self._headers):
323
+ # As in the JavaScript library, a chain that took on an empty one has no room to grow.
324
+ raise IndexError('offset is out of bounds')
325
+ self._headers[h * HEADER_BYTES:(h + 1) * HEADER_BYTES] = header
326
+ self._hashes[h * 32:(h + 1) * 32] = hash_bytes
327
+ self._count += 1
328
+
329
+ def keep_to(self, height):
330
+ """Keep the blocks up to and including this one, where another chain parts from this one near its end."""
331
+ if height < 0 or height >= self._count:
332
+ raise IndexError('no such block')
333
+ self._count = height + 1
334
+ if height < self._last_known:
335
+ self._floor = None
336
+
337
+ def copy(self):
338
+ """A copy that shares nothing with this chain, to try another node's headers on."""
339
+ other = HeaderChain(self.rules, self._web_crypto)
340
+ size = max(self._count, 1)
341
+ other._inside_set(bytearray(self._headers[:size * HEADER_BYTES]), bytearray(self._hashes[:size * 32]), self._count, self._floor)
342
+ return other
343
+
344
+ def adopt(self, other):
345
+ """Take on what another chain holds: a branch that carries more work."""
346
+ headers, hashes, count, floor = other._inside_get()
347
+ self._headers = bytearray(headers)
348
+ self._hashes = bytearray(hashes)
349
+ self._room = len(self._headers) // HEADER_BYTES
350
+ self._count = count
351
+ self._floor = floor
352
+ self._target_bits = -1
353
+
354
+ def work_after(self, height):
355
+ """The work of the blocks after this one, to the latest: the number of hashes their targets ask for, together."""
356
+ work = 0
357
+ for h in range(height + 1, self._count):
358
+ target = _target_of(self._bits_at(h))
359
+ if target > 0:
360
+ work += (1 << 256) // (target + 1)
361
+ return work
362
+
363
+ def fingerprint_at(self, h):
364
+ """The fingerprint of a block, 64 hex characters."""
365
+ return _written(self._hash_at(h))
366
+
367
+ def hash_at(self, h):
368
+ """The hash of a block, as it is named inside the next header and in a request for headers."""
369
+ return self._hash_at(h)
370
+
371
+ def time_at(self, h):
372
+ """The time a block states, in milliseconds."""
373
+ return self._time_at(h) * 1000
374
+
375
+ def height_of(self, fingerprint):
376
+ """The number of the block with this fingerprint, or -1 where the chain does not hold it."""
377
+ if not isinstance(fingerprint, str) or not re.fullmatch(r'[0-9a-f]{64}', fingerprint):
378
+ return -1
379
+ wanted = bytes.fromhex(fingerprint)[::-1]
380
+ for h in range(self._count - 1, -1, -1):
381
+ if self._hashes[h * 32:h * 32 + 32] == wanted:
382
+ return h
383
+ return -1
384
+
385
+ def bytes(self):
386
+ """Every header held, one after another, as a chain file holds them."""
387
+ return bytes(self._headers[:self._count * HEADER_BYTES])
388
+
389
+ # What one chain hands another, and nothing else should reach.
390
+
391
+ def _inside_get(self):
392
+ return (bytes(self._headers[:self._count * HEADER_BYTES]), bytes(self._hashes[:self._count * 32]), self._count, self._floor)
393
+
394
+ def _inside_set(self, headers, hashes, count, floor):
395
+ self._headers = headers
396
+ self._hashes = hashes
397
+ self._room = max(1, len(headers) // HEADER_BYTES)
398
+ self._count = count
399
+ self._floor = floor
400
+ self._target_bits = -1
401
+
402
+
403
+ def start_header_chain(rules=BITCOIN, web_crypto=False):
404
+ """A chain of headers being read, one header at a time. Not part of the
405
+ public interface. "web_crypto" is kept for the same interface as the
406
+ JavaScript library, where it chooses another way to the same SHA-256."""
407
+ return HeaderChain(rules, web_crypto)
408
+
409
+
410
+ def read_header_chain(data, now=None, rules=BITCOIN, partial=False, web_crypto=False):
411
+ """Read a whole chain file into a chain being read. Not part of the public
412
+ interface. Raises a Refusal "headers-invalid" at the first header that
413
+ breaks a rule. A chain that ends before the last known block is refused,
414
+ unless "partial": the command that fetches headers carries such a chain
415
+ on; nothing may count a block in it."""
416
+ if now is None:
417
+ now = now_ms()
418
+ if not isinstance(data, (bytes, bytearray)) or len(data) == 0 or len(data) % HEADER_BYTES != 0:
419
+ raise _invalid('A chain file holds whole headers of 80 bytes each, one after another, from the first block.')
420
+ if len(data) / HEADER_BYTES > MAX_HEADERS:
421
+ raise _invalid(f'The chain holds more than {MAX_HEADERS} headers.')
422
+ data = bytes(data)
423
+ chain = start_header_chain(rules, web_crypto)
424
+ for at in range(0, len(data), HEADER_BYTES):
425
+ chain.add(data[at:at + HEADER_BYTES], now)
426
+ known = rules['known']
427
+ last = known[-1] if len(known) else None
428
+ if not partial and last and chain.height < last[0]:
429
+ raise _invalid(f'The chain ends at block {chain.height}, before block {last[0]}, which is known to be part of it. Fetch more headers.')
430
+ return chain
431
+
432
+
433
+ def check_header_chain(data, now=None):
434
+ """Check a chain of Bitcoin block headers held in a file: every header, from
435
+ the chain's first block, by the rules above. Nothing is asked of anyone.
436
+
437
+ data: the headers, 80 bytes each, one after another, from the first
438
+ block. now: this device's clock, in milliseconds; by default the
439
+ device's own.
440
+
441
+ Returns what answer_for gives: the number and fingerprint of the latest
442
+ block, the time it states, and a way to find a block: its number, and
443
+ how many blocks come after it in this chain.
444
+
445
+ Raises a Refusal "headers-invalid", at the first header that breaks a rule.
446
+ """
447
+ return answer_for(read_header_chain(data, now=now))
448
+
449
+
450
+ def answer_for(chain):
451
+ """What check_header_chain gives, for a chain that has been read: a dict
452
+ with "height", "tip", "tipWhen" and "find", a function that gives
453
+ {"height", "after"} for a block's fingerprint, or None. Not part of the
454
+ public interface."""
455
+ height = chain.height
456
+
457
+ def find(fingerprint):
458
+ at = chain.height_of(fingerprint)
459
+ return None if at < 0 else {'height': at, 'after': height - at}
460
+
461
+ return {'height': height, 'tip': chain.fingerprint_at(height), 'tipWhen': format_time(chain.time_at(height)), 'find': find}
462
+
463
+
464
+ def blocks_in_chain(result, chain):
465
+ """The blocks a check's block time-stamps lead to that a chain of headers
466
+ holds with at least MIN_BLOCKS_AFTER blocks after them: the blocks a
467
+ person who checked that chain can name as trusted. Each block
468
+ time-stamp the result shows is looked for, with what was found.
469
+
470
+ result: what check_book or check_show returned. chain: what
471
+ check_header_chain returned.
472
+
473
+ Returns {"blocks": [fingerprint, ...], "found": [{"block", "stated",
474
+ "height", "after", "counted"}, ...]}. "stated" is left out where the
475
+ time-stamp states no number, as JSON leaves out a member that is
476
+ undefined in JavaScript.
477
+ """
478
+ find = chain['find'] if isinstance(chain, dict) else chain.find
479
+ stamps = []
480
+ entries = result.get('entries')
481
+ for e in [] if entries is None else entries:
482
+ given = e.get('stamps')
483
+ stamps.extend([] if given is None else given)
484
+ held = result.get('held')
485
+ for h in [] if held is None else held:
486
+ given = h.get('stamps')
487
+ stamps.extend([] if given is None else given)
488
+ found = []
489
+ seen = set()
490
+ for s in stamps:
491
+ if s is None:
492
+ raise TypeError('a time-stamp that is null has no kind')
493
+ if not isinstance(s, dict):
494
+ continue
495
+ authority = s.get('authority')
496
+ if s.get('kind') != 'block' or not isinstance(authority, str) or authority in seen:
497
+ continue
498
+ seen.add(authority)
499
+ at = find(authority)
500
+ item = {'block': authority}
501
+ if 'height' in s:
502
+ item['stated'] = s['height']
503
+ item['height'] = at['height'] if at is not None else None
504
+ item['after'] = at['after'] if at is not None else None
505
+ item['counted'] = at is not None and at['after'] >= MIN_BLOCKS_AFTER
506
+ found.append(item)
507
+ return {'blocks': [f['block'] for f in found if f['counted']], 'found': found}
provared/jws.py ADDED
@@ -0,0 +1,164 @@
1
+ # The signed envelope: JSON Web Signature (RFC 7515) in the general JSON
2
+ # serialisation (section 7.2.1), as the format description, sections 3.1 to
3
+ # 3.5, narrows it.
4
+ #
5
+ # Each layout is built in one place, here, and used by both the writers and
6
+ # the checker.
7
+
8
+ from . import _js
9
+ from .encoding import Refusal, canonical_json, fingerprint, from_base64url, from_utf8, parse_canonical, to_base64url, utf8
10
+
11
+ MAX_RECORD_BYTES = 65536
12
+ """The largest record, in bytes."""
13
+
14
+
15
+ def _two(kind):
16
+ return [f'{{"typ":"vnd.provared.{kind}.v0+json","alg":"Ed25519"}}', f'{{"typ":"vnd.provared.{kind}.v0+json","alg":"ML-DSA-87"}}']
17
+
18
+
19
+ def _passkey(kind):
20
+ return [f'{{"typ":"vnd.provared.{kind}.v0+json","alg":"prova.red/webauthn/v0"}}']
21
+
22
+
23
+ KINDS = {
24
+ 'slip': {'type': 'provared.slip.v0', 'passkey': True, 'protected': _passkey('slip')},
25
+ 'stub': {'type': 'provared.stub.v0', 'protected': _two('stub')},
26
+ 'countersignature': {'type': 'provared.countersignature.v0', 'protected': _two('countersignature')},
27
+ 'approval': {'type': 'provared.approval.v0', 'passkey': True, 'protected': _passkey('approval')},
28
+ 'refusal': {'type': 'provared.refusal.v0', 'protected': _two('refusal')},
29
+ 'terms': {'type': 'provared.terms.v0', 'protected': _two('terms')},
30
+ 'cancellation': {'type': 'provared.cancellation.v0', 'passkey': True, 'protected': _passkey('cancellation')},
31
+ 'acknowledgement': {'type': 'provared.acknowledgement.v0', 'protected': _two('acknowledgement')},
32
+ 'vouching': {'type': 'provared.vouching.v0', 'protected': _two('vouching')},
33
+ 'withdrawal': {'type': 'provared.withdrawal.v0', 'protected': _two('withdrawal')},
34
+ 'pass': {'type': 'provared.pass.v0', 'protected': _two('pass')},
35
+ 'seal': {
36
+ 'type': 'provared.seal.v0',
37
+ 'protected': _two('seal') + ['{"typ":"vnd.provared.seal.v0+json","alg":"SLH-DSA-SHA2-256s"}'],
38
+ },
39
+ }
40
+ """The kinds of record. For each: the "type" of its content, and the exact
41
+ protected header of each signature, in order. A checker compares protected
42
+ headers byte for byte with these texts and interprets nothing else."""
43
+
44
+ _ALL_PROTECTED = {text: kind for kind, k in KINDS.items() for text in k['protected']}
45
+
46
+
47
+ def protected_headers(kind):
48
+ """The protected headers of a kind, in base64url, in signature order."""
49
+ return [to_base64url(utf8(text)) for text in KINDS[kind]['protected']]
50
+
51
+
52
+ def signing_input(protected_b64, payload_b64):
53
+ """The bytes a signature covers (RFC 7515 section 5.1): the protected header
54
+ in base64url, a full stop, the content in base64url."""
55
+ return utf8(protected_b64 + '.' + payload_b64)
56
+
57
+
58
+ def record_size(record):
59
+ """The size of a record, as the format description, section 3.1, measures it:
60
+ the lengths of its "payload" and of every text value in its signature
61
+ entries, added up."""
62
+ payload = record.get('payload')
63
+ size = _js.utf16_length(payload) if isinstance(payload, str) else 0
64
+ signatures = record.get('signatures')
65
+ if not isinstance(signatures, list):
66
+ return size
67
+ for s in signatures:
68
+ if not isinstance(s, (dict, list)):
69
+ continue
70
+ values = s.values() if isinstance(s, dict) else s
71
+ for v in values:
72
+ size += _js.utf16_length(v) if isinstance(v, str) else 0
73
+ header = s.get('header') if isinstance(s, dict) else None
74
+ if isinstance(header, (dict, list)):
75
+ for v in header.values() if isinstance(header, dict) else header:
76
+ size += _js.utf16_length(v) if isinstance(v, str) else 0
77
+ return size
78
+
79
+
80
+ def within_size(record):
81
+ """A writer's check that what it has made is small enough to be accepted."""
82
+ if record_size(record) > MAX_RECORD_BYTES:
83
+ raise Refusal('too-large', 'The record would be larger than 65,536 bytes.')
84
+ return record
85
+
86
+
87
+ def _exact_members(obj, names, what):
88
+ if not isinstance(obj, dict):
89
+ raise Refusal('bad-envelope', f'{what} must be an object.')
90
+ have = _js.sort_strings(obj.keys())
91
+ want = _js.sort_strings(names)
92
+ if have != want:
93
+ raise Refusal('bad-envelope', f'{what} must hold exactly: {", ".join(want)}.')
94
+
95
+
96
+ class ParsedRecord:
97
+ __slots__ = ('kind', 'payload_b64', 'content_bytes', 'content', 'fingerprint', 'signatures')
98
+
99
+ def __init__(self, kind, payload_b64, content_bytes, content, fp, signatures):
100
+ self.kind = kind
101
+ self.payload_b64 = payload_b64
102
+ self.content_bytes = content_bytes
103
+ self.content = content
104
+ self.fingerprint = fp
105
+ self.signatures = signatures
106
+
107
+
108
+ def parse_record(record, kind):
109
+ """Read a record and confirm its envelope, its labels and the canonical form
110
+ of its content. This checks no signature and no field of the content
111
+ other than "type"."""
112
+ expected = KINDS[kind]
113
+ passkey = expected.get('passkey', False)
114
+ _exact_members(record, ['payload', 'signatures'], 'A record')
115
+ if not isinstance(record['payload'], str):
116
+ raise Refusal('bad-envelope', '"payload" must be text.')
117
+ if not isinstance(record['signatures'], list):
118
+ raise Refusal('bad-envelope', '"signatures" must be a list.')
119
+
120
+ # The size is measured before anything is decoded.
121
+ if record_size(record) > MAX_RECORD_BYTES:
122
+ raise Refusal('too-large', 'The record is larger than 65,536 bytes.')
123
+
124
+ # Labels first: what kind of record does each signature say this is?
125
+ signatures = []
126
+ seen = []
127
+ for s in record['signatures']:
128
+ _exact_members(s, ['header', 'protected', 'signature'] if passkey else ['protected', 'signature'], 'A signature entry')
129
+ if not isinstance(s['protected'], str) or not isinstance(s['signature'], str):
130
+ raise Refusal('bad-envelope', '"protected" and "signature" must be text.')
131
+ text = from_utf8(from_base64url(s['protected']), 'unknown-type')
132
+ if text not in _ALL_PROTECTED:
133
+ raise Refusal('unknown-type', 'A signature carries a label that is not one of the known labels.')
134
+ if _ALL_PROTECTED[text] != kind:
135
+ raise Refusal('payload-type-mismatch', f'A signature is labelled as a {_ALL_PROTECTED[text]}, but a {kind} is expected here.')
136
+ seen.append(text)
137
+ entry = {'protected_b64': s['protected'], 'signature': from_base64url(s['signature'])}
138
+ if passkey:
139
+ _exact_members(s['header'], ['authenticatorData', 'clientDataJSON'], 'The "header" of a passkey signature')
140
+ entry['header'] = s['header']
141
+ signatures.append(entry)
142
+ if seen != expected['protected']:
143
+ if passkey:
144
+ message = f'A {kind} carries exactly one signature.'
145
+ elif kind == 'seal':
146
+ message = 'A seal carries exactly three signatures: Ed25519, ML-DSA-87, then SLH-DSA-SHA2-256s.'
147
+ else:
148
+ message = f'A {kind} carries exactly two signatures: Ed25519, then ML-DSA-87.'
149
+ raise Refusal('bad-signatures-layout', message)
150
+
151
+ content_bytes = from_base64url(record['payload'])
152
+ content = parse_canonical(from_utf8(content_bytes, 'payload-not-canonical'))
153
+ if not isinstance(content, dict):
154
+ raise Refusal('bad-field', 'The content must be an object.')
155
+ if content.get('type') != expected['type']:
156
+ # Nothing from the content goes into the message: it is untrusted, and need not even be text.
157
+ raise Refusal('payload-type-mismatch', f'The content does not say it is a {kind}, which is what is expected here.')
158
+ return ParsedRecord(kind, record['payload'], content_bytes, content, fingerprint(content_bytes), signatures)
159
+
160
+
161
+ def encode_content(content):
162
+ """Write content in the canonical form and return what a signer needs."""
163
+ content_bytes = utf8(canonical_json(content))
164
+ return content_bytes, to_base64url(content_bytes)