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/der.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# A small, strict reader for DER, the byte layout that time-stamps and
|
|
2
|
+
# certificates use (ITU-T X.690). It reads; it never writes. Anything not in
|
|
3
|
+
# the one form DER allows is refused.
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .encoding import Refusal, _days_from_civil
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def bad_stamp(why):
|
|
12
|
+
return Refusal('stamp-bad-data', f'A time-stamp is not laid out as its standard sets out: {why}')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TAG:
|
|
16
|
+
"""The tags this reader meets."""
|
|
17
|
+
|
|
18
|
+
BOOLEAN = 0x01
|
|
19
|
+
INTEGER = 0x02
|
|
20
|
+
BIT_STRING = 0x03
|
|
21
|
+
OCTET_STRING = 0x04
|
|
22
|
+
NULL = 0x05
|
|
23
|
+
OID = 0x06
|
|
24
|
+
UTC_TIME = 0x17
|
|
25
|
+
GENERALIZED_TIME = 0x18
|
|
26
|
+
SEQUENCE = 0x30
|
|
27
|
+
SET = 0x31
|
|
28
|
+
CONTEXT_0 = 0xA0
|
|
29
|
+
CONTEXT_1 = 0xA1
|
|
30
|
+
CONTEXT_3 = 0xA3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Element:
|
|
34
|
+
"""One element: "from_" is its first byte, "start" and "end" bound its content."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ('tag', 'from_', 'start', 'end')
|
|
37
|
+
|
|
38
|
+
def __init__(self, tag, from_, start, end):
|
|
39
|
+
self.tag = tag
|
|
40
|
+
self.from_ = from_
|
|
41
|
+
self.start = start
|
|
42
|
+
self.end = end
|
|
43
|
+
|
|
44
|
+
def as_dict(self):
|
|
45
|
+
"""The element as JavaScript gives it: {tag, from, start, end}."""
|
|
46
|
+
return {'tag': self.tag, 'from': self.from_, 'start': self.start, 'end': self.end}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_element(data, at, end=None):
|
|
50
|
+
"""Read the element that begins at "at". It must lie before "end"."""
|
|
51
|
+
if end is None:
|
|
52
|
+
end = len(data)
|
|
53
|
+
if at + 2 > end:
|
|
54
|
+
raise bad_stamp('it is cut short.')
|
|
55
|
+
tag = data[at]
|
|
56
|
+
if (tag & 0x1F) == 0x1F:
|
|
57
|
+
raise bad_stamp('it holds a tag of a kind that is not used here.')
|
|
58
|
+
length = data[at + 1]
|
|
59
|
+
start = at + 2
|
|
60
|
+
if length & 0x80:
|
|
61
|
+
count = length & 0x7F
|
|
62
|
+
# Never the indefinite form, and never more than four bytes of length.
|
|
63
|
+
if count == 0 or count > 4:
|
|
64
|
+
raise bad_stamp('it holds a length in a form that is not allowed.')
|
|
65
|
+
if start + count > end:
|
|
66
|
+
raise bad_stamp('it is cut short.')
|
|
67
|
+
length = 0
|
|
68
|
+
for i in range(count):
|
|
69
|
+
length = length * 256 + data[start + i]
|
|
70
|
+
if data[start] == 0 or length < 128:
|
|
71
|
+
raise bad_stamp('it holds a length that is not in its shortest form.')
|
|
72
|
+
start += count
|
|
73
|
+
if length > end - start:
|
|
74
|
+
raise bad_stamp('a part is longer than what holds it.')
|
|
75
|
+
return Element(tag, at, start, start + length)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def children(data, parent, most=32):
|
|
79
|
+
"""The elements inside a constructed element, in order. More than "most" is refused."""
|
|
80
|
+
out = []
|
|
81
|
+
at = parent.start
|
|
82
|
+
while at < parent.end:
|
|
83
|
+
if len(out) >= most:
|
|
84
|
+
raise bad_stamp('a part holds too many items.')
|
|
85
|
+
child = read_element(data, at, parent.end)
|
|
86
|
+
out.append(child)
|
|
87
|
+
at = child.end
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def item(elements, i):
|
|
92
|
+
"""elements[i], or None where there is no such item, as JavaScript gives undefined."""
|
|
93
|
+
return elements[i] if 0 <= i < len(elements) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def expect(element, tag, what):
|
|
97
|
+
"""Confirm an element's tag, and hand the element back."""
|
|
98
|
+
if element is None or element.tag != tag:
|
|
99
|
+
raise bad_stamp(f'{what} is missing or of the wrong kind.')
|
|
100
|
+
return element
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def content_of(data, element):
|
|
104
|
+
"""The content bytes of an element."""
|
|
105
|
+
return bytes(data[element.start:element.end])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def whole_of(data, element):
|
|
109
|
+
"""The whole element, tag and length included."""
|
|
110
|
+
return bytes(data[element.from_:element.end])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def hex_of(data, element):
|
|
114
|
+
"""The content of an element as lower-case hexadecimal: how object identifiers are compared here."""
|
|
115
|
+
return bytes(data[element.start:element.end]).hex()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_UTC_TIME = re.compile(r'([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])Z')
|
|
119
|
+
_GENERALIZED_TIME = re.compile(r'([0-9]{4})([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])(?:\.([0-9]{1,9}))?Z')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _days_in_month(y, m):
|
|
123
|
+
if m == 2:
|
|
124
|
+
return 29 if (y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)) else 28
|
|
125
|
+
return 30 if m in (4, 6, 9, 11) else 31
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def time_of(data, element):
|
|
129
|
+
"""A time, from the two forms DER allows: "YYMMDDHHMMSSZ" (UTCTime) and
|
|
130
|
+
"YYYYMMDDHHMMSS[.f]Z" (GeneralizedTime). Returns milliseconds."""
|
|
131
|
+
text = ''.join(chr(b) for b in data[element.start:element.end])
|
|
132
|
+
groups = None
|
|
133
|
+
if element.tag == TAG.UTC_TIME:
|
|
134
|
+
m = _UTC_TIME.fullmatch(text)
|
|
135
|
+
if m:
|
|
136
|
+
groups = list(m.groups()) + [None]
|
|
137
|
+
groups[0] = ('20' if int(groups[0]) < 50 else '19') + groups[0]
|
|
138
|
+
elif element.tag == TAG.GENERALIZED_TIME:
|
|
139
|
+
m = _GENERALIZED_TIME.fullmatch(text)
|
|
140
|
+
# DER allows no trailing zero in the fraction.
|
|
141
|
+
if m and not (m.group(7) and m.group(7).endswith('0')):
|
|
142
|
+
groups = list(m.groups())
|
|
143
|
+
if groups is None:
|
|
144
|
+
raise bad_stamp('a time is not written in the form DER allows.')
|
|
145
|
+
y, mo, d, h, mi, s = (int(g) for g in groups[:6])
|
|
146
|
+
fraction = groups[6]
|
|
147
|
+
# JavaScript's Date.UTC reads a year from 0 to 99 as 1900 to 1999, so
|
|
148
|
+
# such a year never names the moment it says, and is refused there.
|
|
149
|
+
if y <= 99 or not (1 <= mo <= 12) or not (1 <= d <= _days_in_month(y, mo)) or h > 23 or mi > 59 or s > 59:
|
|
150
|
+
raise bad_stamp('a time names a moment that does not exist.')
|
|
151
|
+
ms = ((_days_from_civil(y, mo, d) * 24 + h) * 60 + mi) * 60000 + s * 1000
|
|
152
|
+
if fraction:
|
|
153
|
+
ms += math.floor(float('0.' + fraction) * 1000)
|
|
154
|
+
return ms
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ecdsa_to_raw(der, size):
|
|
158
|
+
"""An ECDSA signature from its DER form (two whole numbers) to the two
|
|
159
|
+
numbers side by side, each of "size" bytes, as Web Crypto wants them.
|
|
160
|
+
size: 32 for P-256, 48 for P-384."""
|
|
161
|
+
der = bytes(der)
|
|
162
|
+
outer = expect(read_element(der, 0), TAG.SEQUENCE, 'the signature')
|
|
163
|
+
if outer.end != len(der):
|
|
164
|
+
raise bad_stamp('a signature has bytes left over.')
|
|
165
|
+
parts = children(der, outer, 2)
|
|
166
|
+
if len(parts) != 2:
|
|
167
|
+
raise bad_stamp('a signature does not hold two numbers.')
|
|
168
|
+
out = bytearray(size * 2)
|
|
169
|
+
for i, part in enumerate(parts):
|
|
170
|
+
expect(part, TAG.INTEGER, 'a number of the signature')
|
|
171
|
+
digits = content_of(der, part)
|
|
172
|
+
if len(digits) == 0 or digits[0] & 0x80:
|
|
173
|
+
raise bad_stamp('a signature holds a number that is not positive.')
|
|
174
|
+
if len(digits) > 1 and digits[0] == 0 and not (digits[1] & 0x80):
|
|
175
|
+
raise bad_stamp('a signature holds a number not in its shortest form.')
|
|
176
|
+
if digits[0] == 0:
|
|
177
|
+
digits = digits[1:]
|
|
178
|
+
if len(digits) > size:
|
|
179
|
+
raise bad_stamp('a signature holds a number that is too large.')
|
|
180
|
+
at = i * size + (size - len(digits))
|
|
181
|
+
out[at:at + len(digits)] = digits
|
|
182
|
+
return bytes(out)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def validate_der(data, element):
|
|
186
|
+
"""Walk a whole element and everything inside it, and refuse anything that
|
|
187
|
+
is not strict DER: a constructed part whose items do not fill it exactly,
|
|
188
|
+
a whole number or an object identifier that is not in its shortest form,
|
|
189
|
+
a truth value that is not written in the one way DER allows. Parts this
|
|
190
|
+
checker does not otherwise read are still read here, so that two checkers
|
|
191
|
+
cannot disagree about whether a time-stamp is well formed."""
|
|
192
|
+
budget = [4000]
|
|
193
|
+
|
|
194
|
+
def walk(e, depth):
|
|
195
|
+
budget[0] -= 1
|
|
196
|
+
if budget[0] < 0 or depth > 24:
|
|
197
|
+
raise bad_stamp('it holds too many parts, or parts nested too deeply.')
|
|
198
|
+
length = e.end - e.start
|
|
199
|
+
if e.tag & 0x20:
|
|
200
|
+
at = e.start
|
|
201
|
+
while at < e.end:
|
|
202
|
+
child = read_element(data, at, e.end)
|
|
203
|
+
walk(child, depth + 1)
|
|
204
|
+
at = child.end
|
|
205
|
+
return
|
|
206
|
+
if e.tag == TAG.INTEGER:
|
|
207
|
+
if length == 0:
|
|
208
|
+
raise bad_stamp('a whole number is empty.')
|
|
209
|
+
if length > 1:
|
|
210
|
+
first = data[e.start]
|
|
211
|
+
second = data[e.start + 1]
|
|
212
|
+
if (first == 0x00 and not (second & 0x80)) or (first == 0xFF and second & 0x80):
|
|
213
|
+
raise bad_stamp('a whole number is not in its shortest form.')
|
|
214
|
+
elif e.tag == TAG.OID:
|
|
215
|
+
if length == 0 or data[e.end - 1] & 0x80:
|
|
216
|
+
raise bad_stamp('an object identifier is empty or cut short.')
|
|
217
|
+
for i in range(e.start, e.end):
|
|
218
|
+
if data[i] == 0x80 and (i == e.start or not (data[i - 1] & 0x80)):
|
|
219
|
+
raise bad_stamp('an object identifier is not in its shortest form.')
|
|
220
|
+
elif e.tag == TAG.BOOLEAN:
|
|
221
|
+
if length != 1 or data[e.start] not in (0x00, 0xFF):
|
|
222
|
+
raise bad_stamp('a truth value is not written as DER allows.')
|
|
223
|
+
elif e.tag == TAG.NULL:
|
|
224
|
+
if length != 0:
|
|
225
|
+
raise bad_stamp('an empty value is not empty.')
|
|
226
|
+
elif e.tag == TAG.BIT_STRING:
|
|
227
|
+
if length == 0 or data[e.start] > 7:
|
|
228
|
+
raise bad_stamp('a string of bits is not written as DER allows.')
|
|
229
|
+
|
|
230
|
+
walk(element, 0)
|
provared/encoding.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Bytes, base64url, the canonical form of JSON, fingerprints and times.
|
|
2
|
+
#
|
|
3
|
+
# Nothing here is specific to a kind of record. Each function does exactly
|
|
4
|
+
# what its namesake in the JavaScript library's src/encoding.js does.
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import datetime as _datetime
|
|
11
|
+
import time as _time
|
|
12
|
+
|
|
13
|
+
from . import _js
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Refusal(Exception):
|
|
17
|
+
"""A refusal: a named reason why a record does not check. A failed check is
|
|
18
|
+
a normal answer, not a crash, so callers catch this and report its code."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, code, message):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
self.message = message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def problem_from(e):
|
|
27
|
+
"""Turn anything raised while checking into a problem to report. A Refusal
|
|
28
|
+
keeps its code. Anything else means the checker itself met something it
|
|
29
|
+
did not expect: that is reported as "check-failed" and is never a pass."""
|
|
30
|
+
if isinstance(e, Refusal):
|
|
31
|
+
return {'code': e.code, 'message': e.message}
|
|
32
|
+
return {'code': 'check-failed', 'message': 'The checker met something it did not expect here. This is treated as not intact.'}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
MAX_NUMBER = _js.MAX_SAFE_INTEGER
|
|
36
|
+
"""The largest whole number the format allows (2^53 - 1)."""
|
|
37
|
+
|
|
38
|
+
MAX_DEPTH = 8
|
|
39
|
+
"""How deep the content of a record or a line of a book may nest."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def utf8(text):
|
|
43
|
+
"""TextEncoder.encode: a surrogate without its other half becomes U+FFFD."""
|
|
44
|
+
try:
|
|
45
|
+
return text.encode('utf-8')
|
|
46
|
+
except UnicodeEncodeError:
|
|
47
|
+
return text.encode('utf-16-le', 'surrogatepass').decode('utf-16-le', 'replace').encode('utf-8')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def from_utf8(data, code='not-json'):
|
|
51
|
+
"""Strict UTF-8 decoding. A byte order mark is kept in the text."""
|
|
52
|
+
try:
|
|
53
|
+
return bytes(data).decode('utf-8', 'strict')
|
|
54
|
+
except UnicodeDecodeError:
|
|
55
|
+
raise Refusal(code, 'The bytes are not valid UTF-8 text.') from None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sha256(data):
|
|
59
|
+
"""SHA-256 (FIPS 180-4)."""
|
|
60
|
+
return hashlib.sha256(bytes(data)).digest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- base64url (RFC 4648 section 5, no padding; RFC 7515 section 2) ---
|
|
64
|
+
|
|
65
|
+
_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
|
66
|
+
_LOOKUP = {c: i for i, c in enumerate(_ALPHABET)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_base64url(data):
|
|
70
|
+
data = bytes(data)
|
|
71
|
+
out = []
|
|
72
|
+
i = 0
|
|
73
|
+
n = len(data)
|
|
74
|
+
while i + 2 < n:
|
|
75
|
+
v = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
|
|
76
|
+
out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63] + _ALPHABET[(v >> 6) & 63] + _ALPHABET[v & 63])
|
|
77
|
+
i += 3
|
|
78
|
+
if i + 1 == n:
|
|
79
|
+
v = data[i] << 16
|
|
80
|
+
out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63])
|
|
81
|
+
elif i + 2 == n:
|
|
82
|
+
v = (data[i] << 16) | (data[i + 1] << 8)
|
|
83
|
+
out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63] + _ALPHABET[(v >> 6) & 63])
|
|
84
|
+
return ''.join(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_BASE64URL = re.compile(r'[A-Za-z0-9_-]*')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def from_base64url(text):
|
|
91
|
+
"""Strict decoding: refuses any character outside the alphabet, padding, an
|
|
92
|
+
impossible length, and stray bits in the last character. So one value has
|
|
93
|
+
exactly one text form."""
|
|
94
|
+
if not isinstance(text, str):
|
|
95
|
+
raise Refusal('bad-base64url', 'A base64url value must be text.')
|
|
96
|
+
if _js.utf16_length(text) % 4 == 1:
|
|
97
|
+
raise Refusal('bad-base64url', 'A base64url value has an impossible length.')
|
|
98
|
+
if not _BASE64URL.fullmatch(text):
|
|
99
|
+
raise Refusal('bad-base64url', 'A base64url value holds a character that is not allowed.')
|
|
100
|
+
# The bits of the last character that hold no byte must be zero: four of
|
|
101
|
+
# them after two characters, two after three.
|
|
102
|
+
rest = len(text) % 4
|
|
103
|
+
if rest and _LOOKUP[text[-1]] & (0x0F if rest == 2 else 0x03):
|
|
104
|
+
raise Refusal('bad-base64url', 'A base64url value has stray bits in its last character.')
|
|
105
|
+
return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- the canonical form (RFC 8785, narrowed as the format description says) ---
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _well_formed(text):
|
|
112
|
+
# Python joins a pair of surrogates written as escapes into one character,
|
|
113
|
+
# so any surrogate left is one without its other half.
|
|
114
|
+
chars = text
|
|
115
|
+
i = 0
|
|
116
|
+
n = len(chars)
|
|
117
|
+
while i < n:
|
|
118
|
+
o = ord(chars[i])
|
|
119
|
+
if 0xD800 <= o <= 0xDBFF:
|
|
120
|
+
if i + 1 < n and 0xDC00 <= ord(chars[i + 1]) <= 0xDFFF:
|
|
121
|
+
i += 2
|
|
122
|
+
continue
|
|
123
|
+
return False
|
|
124
|
+
if 0xDC00 <= o <= 0xDFFF:
|
|
125
|
+
return False
|
|
126
|
+
i += 1
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def canonical_json(value, depth=0):
|
|
131
|
+
"""Write a value in the canonical form: RFC 8785, narrowed to objects, lists,
|
|
132
|
+
strings and whole numbers from 0 to 2^53 - 1. For those types RFC 8785 is
|
|
133
|
+
exactly: members sorted by name (by UTF-16 code unit), strings as
|
|
134
|
+
JSON.stringify writes them, no spaces."""
|
|
135
|
+
if isinstance(value, str):
|
|
136
|
+
if not _well_formed(value):
|
|
137
|
+
raise Refusal('payload-not-canonical', 'A string is not well-formed Unicode.')
|
|
138
|
+
return _js.string(value)
|
|
139
|
+
if _js.is_number(value):
|
|
140
|
+
if not _js.is_safe_integer(value) or value < 0 or (isinstance(value, float) and value == 0 and str(value).startswith('-')):
|
|
141
|
+
raise Refusal('payload-not-canonical', 'A number must be a whole number from 0 to 9,007,199,254,740,991.')
|
|
142
|
+
return _js.number_text(value)
|
|
143
|
+
if not isinstance(value, (dict, list)):
|
|
144
|
+
raise Refusal('payload-not-canonical', 'Only objects, lists, strings and whole numbers are allowed.')
|
|
145
|
+
if depth >= MAX_DEPTH:
|
|
146
|
+
raise Refusal('payload-not-canonical', 'The content nests too deeply.')
|
|
147
|
+
if isinstance(value, list):
|
|
148
|
+
return '[' + ','.join(canonical_json(item, depth + 1) for item in value) + ']'
|
|
149
|
+
for name in value:
|
|
150
|
+
if not isinstance(name, str):
|
|
151
|
+
raise Refusal('payload-not-canonical', 'Only objects, lists, strings and whole numbers are allowed.')
|
|
152
|
+
names = _js.sort_strings(value.keys())
|
|
153
|
+
return '{' + ','.join(canonical_json(n, depth + 1) + ':' + canonical_json(value[n], depth + 1) for n in names) + '}'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_canonical(text, code='payload-not-canonical'):
|
|
157
|
+
"""Read JSON text that must already be in the canonical form. The text is
|
|
158
|
+
parsed, written again canonically, and refused if the two differ. This
|
|
159
|
+
refuses a repeated member name, stray spaces and unusual number forms."""
|
|
160
|
+
try:
|
|
161
|
+
value = _js.parse(text)
|
|
162
|
+
except ValueError:
|
|
163
|
+
raise Refusal(code if code == 'payload-not-canonical' else 'not-json', 'The text is not JSON.') from None
|
|
164
|
+
try:
|
|
165
|
+
again = canonical_json(value)
|
|
166
|
+
except Refusal as e:
|
|
167
|
+
raise Refusal(code, e.message) from None
|
|
168
|
+
except RecursionError:
|
|
169
|
+
raise Refusal(code, 'The content nests too deeply.') from None
|
|
170
|
+
if again != text:
|
|
171
|
+
raise Refusal(code, 'The JSON is not in the canonical form (a repeated name, stray spaces, or members out of order).')
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def count_characters(text):
|
|
176
|
+
"""How many characters a text holds, counted as Unicode code points, so that
|
|
177
|
+
every implementation counts the same way."""
|
|
178
|
+
# The second half of a pair does not count again; Python already holds a
|
|
179
|
+
# pair as one character, and a lone second half is not counted, as in JavaScript.
|
|
180
|
+
return sum(1 for c in text if not (0xDC00 <= ord(c) <= 0xDFFF))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def fingerprint(content_bytes):
|
|
184
|
+
"""The fingerprint of a record: SHA-256 of its content bytes, in base64url."""
|
|
185
|
+
return to_base64url(sha256(content_bytes))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- times (RFC 3339, UTC, to the second) ---
|
|
189
|
+
|
|
190
|
+
_TIME = re.compile(r'([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _days_from_civil(y, m, d):
|
|
194
|
+
y -= m <= 2
|
|
195
|
+
# Python's // rounds down, so no correction for years before 0 is needed.
|
|
196
|
+
era = y // 400
|
|
197
|
+
yoe = y - era * 400
|
|
198
|
+
doy = (153 * (m + (-3 if m > 2 else 9)) + 2) // 5 + d - 1
|
|
199
|
+
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
200
|
+
return era * 146097 + doe - 719468
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _civil_from_days(z):
|
|
204
|
+
z += 719468
|
|
205
|
+
era = z // 146097
|
|
206
|
+
doe = z - era * 146097
|
|
207
|
+
yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
|
|
208
|
+
y = yoe + era * 400
|
|
209
|
+
doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
|
|
210
|
+
mp = (5 * doy + 2) // 153
|
|
211
|
+
d = doy - (153 * mp + 2) // 5 + 1
|
|
212
|
+
m = mp + (3 if mp < 10 else -9)
|
|
213
|
+
return y + (m <= 2), m, d
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _days_in_month(y, m):
|
|
217
|
+
if m == 2:
|
|
218
|
+
return 29 if (y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)) else 28
|
|
219
|
+
return 30 if m in (4, 6, 9, 11) else 31
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_time(text):
|
|
223
|
+
"""Milliseconds since 1970, or None if the text is not a time in the one accepted form."""
|
|
224
|
+
if not isinstance(text, str):
|
|
225
|
+
return None
|
|
226
|
+
m = _TIME.fullmatch(text)
|
|
227
|
+
if not m:
|
|
228
|
+
return None
|
|
229
|
+
y, mo, d, h, mi, s = (int(g) for g in m.groups())
|
|
230
|
+
# Refuses dates that do not exist, such as 30 February.
|
|
231
|
+
if not (1 <= mo <= 12 and 1 <= d <= _days_in_month(y, mo) and h <= 23 and mi <= 59 and s <= 59):
|
|
232
|
+
return None
|
|
233
|
+
return ((_days_from_civil(y, mo, d) * 24 + h) * 60 + mi) * 60000 + s * 1000
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def nan_time(text):
|
|
237
|
+
"""parseTime as JavaScript gives it: NaN, not None, for a text that is not
|
|
238
|
+
a time, so that every comparison with it is false, as in JavaScript."""
|
|
239
|
+
ms = parse_time(text)
|
|
240
|
+
return float('nan') if ms is None else ms
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
MAX_TIME_MS = 8.64e15
|
|
244
|
+
_EPOCH = _datetime.datetime(1970, 1, 1, tzinfo=_datetime.timezone.utc)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def format_time(when):
|
|
248
|
+
"""A time, written as YYYY-MM-DDTHH:MM:SSZ. Only a number of milliseconds
|
|
249
|
+
since 1970, or a datetime with a time zone (as a JavaScript Date), is
|
|
250
|
+
read: text, True or a datetime with no time zone would be read by rules
|
|
251
|
+
that differ from one device to another (as local time), so they are
|
|
252
|
+
refused."""
|
|
253
|
+
if isinstance(when, _datetime.datetime) and when.tzinfo is not None:
|
|
254
|
+
when = (when - _EPOCH) // _datetime.timedelta(milliseconds=1)
|
|
255
|
+
if not _js.finite(when) or abs(when) > MAX_TIME_MS:
|
|
256
|
+
raise ValueError('Invalid time value')
|
|
257
|
+
# new Date() cuts a fraction of a millisecond off towards zero.
|
|
258
|
+
ms = int(when)
|
|
259
|
+
days, rest = divmod(ms, 86400000)
|
|
260
|
+
y, mo, d = _civil_from_days(days)
|
|
261
|
+
secs = rest // 1000
|
|
262
|
+
h, secs = divmod(secs, 3600)
|
|
263
|
+
mi, s = divmod(secs, 60)
|
|
264
|
+
year = '%04d' % y if 0 <= y <= 9999 else ('+' if y > 0 else '-') + '%06d' % abs(y)
|
|
265
|
+
# As JavaScript does it: the first 19 characters of toISOString(), and
|
|
266
|
+
# "Z". For a year outside 0 to 9999 that cuts off the seconds.
|
|
267
|
+
iso = '%s-%02d-%02dT%02d:%02d:%02d.000Z' % (year, mo, d, h, mi, s)
|
|
268
|
+
return iso[:19] + 'Z'
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def now_ms():
|
|
272
|
+
"""Date.now()"""
|
|
273
|
+
return _time.time_ns() // 1_000_000
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def random_id():
|
|
277
|
+
"""16 random bytes in base64url: the unique number of a record."""
|
|
278
|
+
return to_base64url(os.urandom(16))
|
provared/fields.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Small checks on the fields of a record's content. Each raises a Refusal
|
|
2
|
+
# with the code "bad-field" and says which field and why.
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from . import _js
|
|
7
|
+
from .actions import is_unknown_reserved
|
|
8
|
+
from .encoding import MAX_NUMBER, Refusal, count_characters, from_base64url, parse_time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fail(path, why):
|
|
12
|
+
return Refusal('bad-field', f'{path}: {why}')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def members(value, required, optional, path):
|
|
16
|
+
"""An object must hold every required member, and no member that is neither
|
|
17
|
+
required nor optional. An unknown member is refused."""
|
|
18
|
+
if not isinstance(value, dict):
|
|
19
|
+
raise fail(path, 'must be an object.')
|
|
20
|
+
for name in required:
|
|
21
|
+
if name not in value:
|
|
22
|
+
raise fail(path, f'"{name}" is missing.')
|
|
23
|
+
for name in value:
|
|
24
|
+
if name not in required and name not in optional:
|
|
25
|
+
raise fail(path, 'holds a member that is not known.')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def text(value, minimum, maximum, path):
|
|
29
|
+
# The quick test on the raw length keeps a huge text from being counted.
|
|
30
|
+
if not isinstance(value, str) or _js.utf16_length(value) > maximum * 2 or count_characters(value) < minimum or count_characters(value) > maximum:
|
|
31
|
+
raise fail(path, f'must be text of {minimum} to {maximum} characters.')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def whole_number(value, path):
|
|
35
|
+
if not _js.is_safe_integer(value) or value < 0 or value > MAX_NUMBER:
|
|
36
|
+
raise fail(path, 'must be a whole number, 0 or more.')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_of(value, minimum, maximum, path):
|
|
40
|
+
if not isinstance(value, list) or len(value) < minimum or len(value) > maximum:
|
|
41
|
+
raise fail(path, f'must be a list of {minimum} to {maximum} items.')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_NAME = re.compile(r'[a-z0-9][a-z0-9._-]{0,63}')
|
|
45
|
+
_UNIT = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]{0,15}')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def short_name(value, path):
|
|
49
|
+
"""An action name, or the id of a service."""
|
|
50
|
+
if not isinstance(value, str) or not _NAME.fullmatch(value):
|
|
51
|
+
raise fail(path, 'must be 1 to 64 lower-case letters, digits, full stops, hyphens or underscores.')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def action_name(value, path):
|
|
55
|
+
"""An action name. Names that begin "provared." must be on the shared list."""
|
|
56
|
+
short_name(value, path)
|
|
57
|
+
if is_unknown_reserved(value):
|
|
58
|
+
raise fail(path, 'names that begin "provared." are reserved, and this one is not on the shared list.')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def unit(value, path):
|
|
62
|
+
if not isinstance(value, str) or not _UNIT.fullmatch(value):
|
|
63
|
+
raise fail(path, 'must be 1 to 16 letters, digits, full stops, hyphens or underscores, with no spaces.')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def label(value, path):
|
|
67
|
+
"""A label chosen by whoever wrote the record. Never checked against anything."""
|
|
68
|
+
text(value, 1, 200, path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _base64url_of_length(value, length, path, what):
|
|
72
|
+
try:
|
|
73
|
+
decoded = from_base64url(value)
|
|
74
|
+
except Refusal:
|
|
75
|
+
raise fail(path, f'must be {what}.') from None
|
|
76
|
+
if len(decoded) != length:
|
|
77
|
+
raise fail(path, f'must be {what}.')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def id_(value, path):
|
|
81
|
+
"""A unique number: 16 bytes in base64url."""
|
|
82
|
+
_base64url_of_length(value, 16, path, 'a unique number of 16 bytes in base64url')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fingerprint_text(value, path):
|
|
86
|
+
"""A fingerprint: SHA-256 in base64url."""
|
|
87
|
+
_base64url_of_length(value, 32, path, 'a SHA-256 fingerprint in base64url')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def time(value, path):
|
|
91
|
+
"""The time in milliseconds."""
|
|
92
|
+
ms = parse_time(value)
|
|
93
|
+
if ms is None:
|
|
94
|
+
raise fail(path, 'must be a time written as YYYY-MM-DDTHH:MM:SSZ.')
|
|
95
|
+
return ms
|