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/__init__.py +60 -0
- provared/_js.py +450 -0
- provared/actions.py +84 -0
- provared/approval.py +108 -0
- provared/blockstamp.py +223 -0
- provared/book.py +2320 -0
- provared/check.py +21 -0
- provared/compare.py +266 -0
- provared/cover.py +187 -0
- provared/der.py +230 -0
- provared/encoding.py +278 -0
- provared/fields.py +95 -0
- provared/guard.py +177 -0
- provared/headers.py +507 -0
- provared/jws.py +164 -0
- provared/keys.py +134 -0
- provared/pass_.py +86 -0
- provared/recorder.py +1426 -0
- provared/seal.py +133 -0
- provared/service.py +138 -0
- provared/signatures.py +149 -0
- provared/slip.py +413 -0
- provared/standing.py +198 -0
- provared/stub.py +131 -0
- provared/timestamp.py +456 -0
- provared/tools.py +415 -0
- provared/tree.py +148 -0
- provared/webauthn.py +107 -0
- provared-0.2.0.dist-info/METADATA +176 -0
- provared-0.2.0.dist-info/RECORD +34 -0
- provared-0.2.0.dist-info/WHEEL +5 -0
- provared-0.2.0.dist-info/licenses/LICENSE +202 -0
- provared-0.2.0.dist-info/licenses/LICENSE-CC-BY-4.0.txt +396 -0
- provared-0.2.0.dist-info/top_level.txt +1 -0
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)
|