insumer-verify 1.9.2__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.
- insumer_verify/__init__.py +43 -0
- insumer_verify/_jsjson.py +325 -0
- insumer_verify/_keys.py +225 -0
- insumer_verify/_time.py +76 -0
- insumer_verify/py.typed +0 -0
- insumer_verify/verify.py +736 -0
- insumer_verify-1.9.2.dist-info/METADATA +214 -0
- insumer_verify-1.9.2.dist-info/RECORD +9 -0
- insumer_verify-1.9.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""insumer-verify: verifier for InsumerAPI attestations and wallet trust profiles.
|
|
2
|
+
|
|
3
|
+
from insumer_verify import verify_attestation, verify_trust_profile
|
|
4
|
+
|
|
5
|
+
result = verify_attestation(response_json, jwks_url="https://insumermodel.com/.well-known/jwks.json")
|
|
6
|
+
if result["valid"] and response_json["data"]["attestation"]["pass"]:
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
Five independent verdicts on an attestation (signature, condition hashes,
|
|
10
|
+
freshness, expiry, post-quantum companion), four on a trust profile. The
|
|
11
|
+
Python and JavaScript packages implement the same specification and pass the
|
|
12
|
+
same published test vectors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._jsjson import MAX_CANONICAL_DEPTH, CanonicalDepthError, canonicalize
|
|
16
|
+
from ._keys import DEFAULT_JWKS_URL, PUBLIC_KEY_JWK
|
|
17
|
+
from .verify import (
|
|
18
|
+
DEFAULT_CLOCK_SKEW_SECONDS,
|
|
19
|
+
EXPIRY_BINDING_GRACE_MS,
|
|
20
|
+
classical_attest_preimage,
|
|
21
|
+
classical_trust_preimage,
|
|
22
|
+
condition_hash,
|
|
23
|
+
verify_attestation,
|
|
24
|
+
verify_trust_profile,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "1.9.2"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"verify_attestation",
|
|
31
|
+
"verify_trust_profile",
|
|
32
|
+
"classical_attest_preimage",
|
|
33
|
+
"classical_trust_preimage",
|
|
34
|
+
"condition_hash",
|
|
35
|
+
"canonicalize",
|
|
36
|
+
"CanonicalDepthError",
|
|
37
|
+
"DEFAULT_CLOCK_SKEW_SECONDS",
|
|
38
|
+
"EXPIRY_BINDING_GRACE_MS",
|
|
39
|
+
"MAX_CANONICAL_DEPTH",
|
|
40
|
+
"DEFAULT_JWKS_URL",
|
|
41
|
+
"PUBLIC_KEY_JWK",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""JavaScript-compatible JSON serialization.
|
|
2
|
+
|
|
3
|
+
Every preimage and condition hash InsumerAPI signs is defined by the bytes
|
|
4
|
+
``JSON.stringify`` produces in the issuer's runtime. Python's ``json`` module
|
|
5
|
+
differs from it in corners that change those bytes: how non-integral and very
|
|
6
|
+
large numbers are printed, the order in which object keys are emitted when some
|
|
7
|
+
of them look like array indexes, how keys are sorted (code units, not code
|
|
8
|
+
points), and how an array "replacer" filters nested objects. This module
|
|
9
|
+
reproduces the JavaScript behaviour exactly rather than approximating it with
|
|
10
|
+
``json.dumps``.
|
|
11
|
+
|
|
12
|
+
Nothing here is specific to attestations. It is the serializer the verifier
|
|
13
|
+
needs, kept separate so that it can be tested against known JavaScript output.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from typing import Any, List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
#: Maximum nesting depth this verifier will canonicalize.
|
|
22
|
+
#:
|
|
23
|
+
#: Canonicalization runs alongside signature verification, not after it, so the
|
|
24
|
+
#: recursive walk below is reachable by anyone holding an artifact: a signature
|
|
25
|
+
#: does not have to be valid to get here. 128 open containers matches the bound
|
|
26
|
+
#: the JavaScript reference verifier applies, so a verifier that refuses here
|
|
27
|
+
#: refuses what it refuses. The deepest artifact in the published conformance
|
|
28
|
+
#: corpus nests 9 levels.
|
|
29
|
+
MAX_CANONICAL_DEPTH = 128
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CanonicalDepthError(ValueError):
|
|
33
|
+
"""Raised when an artifact nests past :data:`MAX_CANONICAL_DEPTH`.
|
|
34
|
+
|
|
35
|
+
Callers turn this into a failed check: it is a refusal to verify, never a
|
|
36
|
+
passing verdict.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, limit: int = MAX_CANONICAL_DEPTH) -> None:
|
|
40
|
+
super().__init__(f"Artifact nests deeper than {limit} levels; refusing to canonicalize")
|
|
41
|
+
self.limit = limit
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Strings ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
_SHORT_ESCAPES = {
|
|
47
|
+
'"': '\\"',
|
|
48
|
+
"\\": "\\\\",
|
|
49
|
+
"\b": "\\b",
|
|
50
|
+
"\f": "\\f",
|
|
51
|
+
"\n": "\\n",
|
|
52
|
+
"\r": "\\r",
|
|
53
|
+
"\t": "\\t",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def js_string(s: str) -> str:
|
|
58
|
+
"""``JSON.stringify`` of a string.
|
|
59
|
+
|
|
60
|
+
Escapes the quote, the backslash, the five short control escapes, every
|
|
61
|
+
other control character below U+0020 as ``\\u00xx`` (lowercase hex), and a
|
|
62
|
+
lone surrogate as ``\\udxxx`` (well-formed ``JSON.stringify``, ES2019).
|
|
63
|
+
Everything else, including non-ASCII text, is emitted as-is.
|
|
64
|
+
"""
|
|
65
|
+
out: List[str] = ['"']
|
|
66
|
+
for ch in s:
|
|
67
|
+
esc = _SHORT_ESCAPES.get(ch)
|
|
68
|
+
if esc is not None:
|
|
69
|
+
out.append(esc)
|
|
70
|
+
continue
|
|
71
|
+
o = ord(ch)
|
|
72
|
+
if o < 0x20 or 0xD800 <= o <= 0xDFFF:
|
|
73
|
+
out.append("\\u%04x" % o)
|
|
74
|
+
else:
|
|
75
|
+
out.append(ch)
|
|
76
|
+
out.append('"')
|
|
77
|
+
return "".join(out)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Numbers ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
_MAX_SAFE_INTEGER = 2**53
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def js_number(n: Any) -> str:
|
|
86
|
+
"""``JSON.stringify`` of a number: ECMAScript ``Number::toString`` in base 10.
|
|
87
|
+
|
|
88
|
+
Integers up to 2**53 print as themselves. Anything else is treated as the
|
|
89
|
+
IEEE double JavaScript would hold, printed with the shortest digit string
|
|
90
|
+
that round-trips, in plain decimal notation for exponents between -7 and
|
|
91
|
+
21 and in ``d.ddde+xx`` form outside that range. NaN and the infinities
|
|
92
|
+
print as ``null``, as ``JSON.stringify`` does.
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(n, bool):
|
|
95
|
+
raise TypeError("booleans are not numbers")
|
|
96
|
+
if isinstance(n, int):
|
|
97
|
+
if abs(n) < _MAX_SAFE_INTEGER:
|
|
98
|
+
return str(n)
|
|
99
|
+
n = float(n)
|
|
100
|
+
if not isinstance(n, float):
|
|
101
|
+
raise TypeError(f"not a number: {type(n).__name__}")
|
|
102
|
+
if math.isnan(n) or math.isinf(n):
|
|
103
|
+
return "null"
|
|
104
|
+
if n == 0:
|
|
105
|
+
return "0" # JavaScript prints -0 as "0"
|
|
106
|
+
|
|
107
|
+
sign = "-" if n < 0 else ""
|
|
108
|
+
text = repr(abs(n)) # shortest round-trip digits
|
|
109
|
+
if "e" in text:
|
|
110
|
+
mantissa, exp_text = text.split("e")
|
|
111
|
+
exponent = int(exp_text)
|
|
112
|
+
else:
|
|
113
|
+
mantissa, exponent = text, 0
|
|
114
|
+
if "." in mantissa:
|
|
115
|
+
int_part, frac_part = mantissa.split(".")
|
|
116
|
+
else:
|
|
117
|
+
int_part, frac_part = mantissa, ""
|
|
118
|
+
digits = int_part + frac_part
|
|
119
|
+
exponent -= len(frac_part) # value = int(digits) * 10**exponent
|
|
120
|
+
digits = digits.lstrip("0")
|
|
121
|
+
stripped = len(digits) - len(digits.rstrip("0"))
|
|
122
|
+
digits = digits[: len(digits) - stripped] if stripped else digits
|
|
123
|
+
exponent += stripped
|
|
124
|
+
k = len(digits) # number of significant digits
|
|
125
|
+
pos = k + exponent # ECMAScript "n": value = 0.digits * 10**pos
|
|
126
|
+
|
|
127
|
+
if k <= pos <= 21:
|
|
128
|
+
return sign + digits + "0" * (pos - k)
|
|
129
|
+
if 0 < pos <= 21:
|
|
130
|
+
return sign + digits[:pos] + "." + digits[pos:]
|
|
131
|
+
if -6 < pos <= 0:
|
|
132
|
+
return sign + "0." + "0" * (-pos) + digits
|
|
133
|
+
e = pos - 1
|
|
134
|
+
e_text = ("+" if e >= 0 else "-") + str(abs(e))
|
|
135
|
+
if k == 1:
|
|
136
|
+
return sign + digits + "e" + e_text
|
|
137
|
+
return sign + digits[0] + "." + digits[1:] + "e" + e_text
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Property order ───────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_array_index(key: str) -> bool:
|
|
144
|
+
# A canonical numeric string below 2**32 - 1: ASCII digits, no leading zero
|
|
145
|
+
# (except "0" itself), and in range.
|
|
146
|
+
if not key or not key.isascii() or not key.isdigit():
|
|
147
|
+
return False
|
|
148
|
+
if len(key) > 1 and key[0] == "0":
|
|
149
|
+
return False
|
|
150
|
+
return int(key) < 2**32 - 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def js_keys(obj: dict) -> List[str]:
|
|
154
|
+
"""``Object.keys`` order: integer-like keys ascending, then the rest in insertion order.
|
|
155
|
+
|
|
156
|
+
``JSON.parse`` applies the same rule, so a verifier that reproduces an
|
|
157
|
+
insertion-order preimage must honour it even though ordinary attestation
|
|
158
|
+
fields never look like array indexes.
|
|
159
|
+
"""
|
|
160
|
+
indexes: List[str] = []
|
|
161
|
+
rest: List[str] = []
|
|
162
|
+
for key in obj:
|
|
163
|
+
if not isinstance(key, str):
|
|
164
|
+
raise TypeError("JSON object keys must be strings")
|
|
165
|
+
(indexes if _is_array_index(key) else rest).append(key)
|
|
166
|
+
indexes.sort(key=int)
|
|
167
|
+
return indexes + rest
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def utf16_sort_key(key: str) -> bytes:
|
|
171
|
+
"""Sort key reproducing the default ``Array.prototype.sort`` on strings.
|
|
172
|
+
|
|
173
|
+
JavaScript compares strings by UTF-16 code units. For characters outside the
|
|
174
|
+
Basic Multilingual Plane that order differs from Python's code-point order,
|
|
175
|
+
so the keys are compared as big-endian UTF-16 bytes instead.
|
|
176
|
+
"""
|
|
177
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Serializers ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def js_stringify(value: Any, depth: int = 0, allow: Optional[Sequence[str]] = None) -> str:
|
|
184
|
+
"""``JSON.stringify(value)`` or, with ``allow``, ``JSON.stringify(value, allow)``.
|
|
185
|
+
|
|
186
|
+
With an allow list (JavaScript's array replacer) every object at every
|
|
187
|
+
nesting level emits only the listed properties, in the list's order. Depth
|
|
188
|
+
is bounded by :data:`MAX_CANONICAL_DEPTH`.
|
|
189
|
+
"""
|
|
190
|
+
if depth > MAX_CANONICAL_DEPTH:
|
|
191
|
+
raise CanonicalDepthError()
|
|
192
|
+
if value is None:
|
|
193
|
+
return "null"
|
|
194
|
+
if value is True:
|
|
195
|
+
return "true"
|
|
196
|
+
if value is False:
|
|
197
|
+
return "false"
|
|
198
|
+
if isinstance(value, (int, float)):
|
|
199
|
+
return js_number(value)
|
|
200
|
+
if isinstance(value, str):
|
|
201
|
+
return js_string(value)
|
|
202
|
+
if isinstance(value, (list, tuple)):
|
|
203
|
+
return "[" + ",".join(js_stringify(v, depth + 1, allow) for v in value) + "]"
|
|
204
|
+
if isinstance(value, dict):
|
|
205
|
+
if allow is None:
|
|
206
|
+
keys: Sequence[str] = js_keys(value)
|
|
207
|
+
else:
|
|
208
|
+
keys = [k for k in allow if k in value]
|
|
209
|
+
parts = [js_string(k) + ":" + js_stringify(value[k], depth + 1, allow) for k in keys]
|
|
210
|
+
return "{" + ",".join(parts) + "}"
|
|
211
|
+
raise TypeError(f"cannot serialize {type(value).__name__}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def canonicalize(value: Any, depth: int = 0) -> str:
|
|
215
|
+
"""Canonical JSON as the v2 signing scheme defines it.
|
|
216
|
+
|
|
217
|
+
Arrays keep their order and canonicalize each element; objects emit their
|
|
218
|
+
keys sorted as JavaScript sorts them (UTF-16 code units), each as
|
|
219
|
+
``JSON.stringify(key) + ":" + canonicalize(value)``; every other value is
|
|
220
|
+
``JSON.stringify(value)``. No whitespace. The sort applies at every nesting
|
|
221
|
+
level. Depth is bounded by :data:`MAX_CANONICAL_DEPTH`.
|
|
222
|
+
"""
|
|
223
|
+
if depth > MAX_CANONICAL_DEPTH:
|
|
224
|
+
raise CanonicalDepthError()
|
|
225
|
+
if isinstance(value, (list, tuple)):
|
|
226
|
+
return "[" + ",".join(canonicalize(v, depth + 1) for v in value) + "]"
|
|
227
|
+
if isinstance(value, dict):
|
|
228
|
+
keys = sorted(js_keys(value), key=utf16_sort_key)
|
|
229
|
+
return "{" + ",".join(js_string(k) + ":" + canonicalize(value[k], depth + 1) for k in keys) + "}"
|
|
230
|
+
return js_stringify(value)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def assert_depth(value: Any, depth: int = 0) -> None:
|
|
234
|
+
"""Depth check for the v1 (bare JSON) paths.
|
|
235
|
+
|
|
236
|
+
v1 preimages are frozen, so the serializer must not alter their bytes. This
|
|
237
|
+
walks the value only to enforce the same bound the v2 path enforces.
|
|
238
|
+
"""
|
|
239
|
+
if depth > MAX_CANONICAL_DEPTH:
|
|
240
|
+
raise CanonicalDepthError()
|
|
241
|
+
if isinstance(value, (list, tuple)):
|
|
242
|
+
for v in value:
|
|
243
|
+
assert_depth(v, depth + 1)
|
|
244
|
+
elif isinstance(value, dict):
|
|
245
|
+
for v in value.values():
|
|
246
|
+
assert_depth(v, depth + 1)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def js_object_keys(value: Any) -> List[str]:
|
|
250
|
+
"""``Object.keys(value)`` for any JSON value: an array yields its indexes, a primitive nothing."""
|
|
251
|
+
if isinstance(value, dict):
|
|
252
|
+
return js_keys(value)
|
|
253
|
+
if isinstance(value, (list, tuple)):
|
|
254
|
+
return [str(i) for i in range(len(value))]
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def v1_condition_canonical(evaluated_condition: Any) -> str:
|
|
259
|
+
"""``JSON.stringify(evaluatedCondition, Object.keys(evaluatedCondition).sort())``.
|
|
260
|
+
|
|
261
|
+
The v1 condition-hash preimage. The sorted top-level key list is passed as
|
|
262
|
+
the replacer array, so top-level keys come out sorted, and a nested object
|
|
263
|
+
(none exists in a real v1 condition) would be filtered to those same names.
|
|
264
|
+
A value that is not an object is serialized the way ``JSON.stringify`` would
|
|
265
|
+
serialize it with that replacer, so the two verifiers agree on every shape.
|
|
266
|
+
"""
|
|
267
|
+
assert_depth(evaluated_condition)
|
|
268
|
+
allow = sorted(js_object_keys(evaluated_condition), key=utf16_sort_key)
|
|
269
|
+
return js_stringify(evaluated_condition, allow=allow)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Structural comparison ────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _same_primitive(a: Any, b: Any) -> bool:
|
|
276
|
+
# JavaScript strict equality on JSON primitives: a boolean never equals a
|
|
277
|
+
# number (Python's True == 1 must not leak in), numbers compare by value,
|
|
278
|
+
# strings and null by identity of kind and value.
|
|
279
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
280
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
281
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
282
|
+
return a == b
|
|
283
|
+
if a is None or b is None:
|
|
284
|
+
return a is None and b is None
|
|
285
|
+
return isinstance(a, str) and isinstance(b, str) and a == b
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def first_claim_difference(a: Any, b: Any, path: str = "", depth: int = 0) -> Optional[str]:
|
|
289
|
+
"""First path at which two parsed JSON values differ, or ``None`` when deeply equal.
|
|
290
|
+
|
|
291
|
+
Objects are compared without regard to member order, arrays in order,
|
|
292
|
+
primitives by value; a member present on one side only is a difference.
|
|
293
|
+
Depth-bounded like every other walker here.
|
|
294
|
+
"""
|
|
295
|
+
if depth > MAX_CANONICAL_DEPTH:
|
|
296
|
+
raise CanonicalDepthError()
|
|
297
|
+
a_obj = isinstance(a, (dict, list, tuple))
|
|
298
|
+
b_obj = isinstance(b, (dict, list, tuple))
|
|
299
|
+
if not a_obj and not b_obj:
|
|
300
|
+
return None if _same_primitive(a, b) else path
|
|
301
|
+
if not a_obj or not b_obj:
|
|
302
|
+
return path
|
|
303
|
+
a_arr = isinstance(a, (list, tuple))
|
|
304
|
+
b_arr = isinstance(b, (list, tuple))
|
|
305
|
+
if a_arr != b_arr:
|
|
306
|
+
return path
|
|
307
|
+
if a_arr:
|
|
308
|
+
if len(a) != len(b):
|
|
309
|
+
return path
|
|
310
|
+
for i, (x, y) in enumerate(zip(a, b)):
|
|
311
|
+
d = first_claim_difference(x, y, f"{path}[{i}]", depth + 1)
|
|
312
|
+
if d is not None:
|
|
313
|
+
return d
|
|
314
|
+
return None
|
|
315
|
+
for k in js_keys(a):
|
|
316
|
+
if k not in b:
|
|
317
|
+
return f"{path}.{k}" if path else k
|
|
318
|
+
for k in js_keys(b):
|
|
319
|
+
if k not in a:
|
|
320
|
+
return f"{path}.{k}" if path else k
|
|
321
|
+
for k in js_keys(a):
|
|
322
|
+
d = first_claim_difference(a[k], b[k], f"{path}.{k}" if path else k, depth + 1)
|
|
323
|
+
if d is not None:
|
|
324
|
+
return d
|
|
325
|
+
return None
|
insumer_verify/_keys.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Key material: the built-in ECDSA key, JWKS resolution by ``kid``, and the signature primitives.
|
|
2
|
+
|
|
3
|
+
The JWKS holds keys of two types. The first three entries are one ECDSA P-256
|
|
4
|
+
key under three ``kid`` values; the last two are RFC 9964 ``AKP`` entries for
|
|
5
|
+
the ML-DSA-65 post-quantum companion key. Keys are selected by ``kid`` and
|
|
6
|
+
never by position.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidSignature
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.utils import (
|
|
21
|
+
decode_dss_signature,
|
|
22
|
+
encode_dss_signature,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
#: InsumerAPI's ECDSA P-256 public key in JWK form. The same key is published
|
|
26
|
+
#: at https://insumermodel.com/.well-known/jwks.json under three kids.
|
|
27
|
+
PUBLIC_KEY_JWK: Dict[str, str] = {
|
|
28
|
+
"kty": "EC",
|
|
29
|
+
"crv": "P-256",
|
|
30
|
+
"x": "JtHPhDPnv8AfP0JSlGutxbOlxreV2Chey27Z76q3V2c",
|
|
31
|
+
"y": "kn34HaxVSJfn8NxwNEBjjLkcrM_GDw1lgnqyADGuc4c",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
DEFAULT_JWKS_URL = "https://insumermodel.com/.well-known/jwks.json"
|
|
35
|
+
JWKS_FETCH_TIMEOUT_SECONDS = 15
|
|
36
|
+
|
|
37
|
+
# Which classical kids may sign which artifact. Attestations and trust profiles
|
|
38
|
+
# share the v1 kid (one frozen scheme) but have distinct v2 kids; a trust kid on
|
|
39
|
+
# an attestation, or the reverse, is a mislabelled artifact and fails Check 1
|
|
40
|
+
# rather than being re-interpreted.
|
|
41
|
+
ATTEST_KIDS = frozenset({"insumer-attest-v1", "insumer-attest-v2"})
|
|
42
|
+
TRUST_KIDS = frozenset({"insumer-attest-v1", "insumer-trust-v2"})
|
|
43
|
+
KNOWN_PQ_KIDS = frozenset({"insumer-attest-pq1", "insumer-trust-pq1"})
|
|
44
|
+
KNOWN_CLASSICAL_KIDS = ATTEST_KIDS | TRUST_KIDS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Encoding helpers ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def b64_decode(text: str) -> bytes:
|
|
51
|
+
"""Standard base64 (what ``atob`` reads), padding tolerated."""
|
|
52
|
+
if not isinstance(text, str):
|
|
53
|
+
raise ValueError("expected a base64 string")
|
|
54
|
+
cleaned = "".join(text.split())
|
|
55
|
+
pad = (-len(cleaned)) % 4
|
|
56
|
+
if pad == 3:
|
|
57
|
+
raise ValueError("invalid base64 length")
|
|
58
|
+
return base64.b64decode(cleaned + "=" * pad, validate=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def b64url_decode(text: str) -> bytes:
|
|
62
|
+
"""base64url with or without padding."""
|
|
63
|
+
if not isinstance(text, str):
|
|
64
|
+
raise ValueError("expected a base64url string")
|
|
65
|
+
cleaned = text.replace("-", "+").replace("_", "/")
|
|
66
|
+
return b64_decode(cleaned)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def b64url_decode_text(text: str) -> str:
|
|
70
|
+
return b64url_decode(text).decode("utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── JWKS loading and selection ───────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_jwks(options: Dict[str, Any], fallback_url: Optional[str] = None) -> Tuple[Dict[str, Any], str]:
|
|
77
|
+
"""The key set to resolve against: a supplied object (nothing fetched) or the JWKS at a URL.
|
|
78
|
+
|
|
79
|
+
Returns the set and a label naming its source for error messages.
|
|
80
|
+
"""
|
|
81
|
+
supplied = options.get("jwks")
|
|
82
|
+
if supplied is not None:
|
|
83
|
+
if not isinstance(supplied, dict) or not isinstance(supplied.get("keys"), list):
|
|
84
|
+
raise ValueError("Supplied jwks has no keys array")
|
|
85
|
+
return supplied, "supplied JWKS"
|
|
86
|
+
url = options.get("jwks_url") or fallback_url or DEFAULT_JWKS_URL
|
|
87
|
+
fetch: Callable[[str], Dict[str, Any]] = options.get("_fetch_json") or _fetch_json
|
|
88
|
+
return fetch(url), f"JWKS at {url}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fetch_json(url: str) -> Dict[str, Any]:
|
|
92
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "insumer-verify-python"})
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(request, timeout=JWKS_FETCH_TIMEOUT_SECONDS) as response: # noqa: S310 (https URL supplied by the caller)
|
|
95
|
+
status = getattr(response, "status", 200)
|
|
96
|
+
if status != 200:
|
|
97
|
+
raise ValueError(f"JWKS fetch failed: {status} {getattr(response, 'reason', '')}".rstrip())
|
|
98
|
+
body = response.read()
|
|
99
|
+
except urllib.error.HTTPError as e:
|
|
100
|
+
raise ValueError(f"JWKS fetch failed: {e.code} {e.reason}") from None
|
|
101
|
+
except urllib.error.URLError as e:
|
|
102
|
+
raise ValueError(f"JWKS fetch failed: {e.reason}") from None
|
|
103
|
+
try:
|
|
104
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
105
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
106
|
+
raise ValueError(f"JWKS fetch failed: response is not JSON ({e})") from None
|
|
107
|
+
if not isinstance(parsed, dict):
|
|
108
|
+
raise ValueError("JWKS fetch failed: response is not a JSON object")
|
|
109
|
+
return parsed
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fetch_jwks_key(options: Dict[str, Any], kid: Optional[str]) -> Dict[str, str]:
|
|
113
|
+
"""The P-256 JWK the response's kid names, or an error naming why none could be selected."""
|
|
114
|
+
jwks, label = load_jwks(options)
|
|
115
|
+
keys = jwks.get("keys")
|
|
116
|
+
if not isinstance(keys, list) or not keys:
|
|
117
|
+
raise ValueError("JWKS response contains no keys")
|
|
118
|
+
# A kid that resolves to nothing is a failure, not a reason to fall back: the
|
|
119
|
+
# first key in the document is not the key the signature claims. A response
|
|
120
|
+
# with no kid cannot select a key at all (spec Section 3.4 makes kid
|
|
121
|
+
# mandatory): the set holds keys of two types, and position is not a contract.
|
|
122
|
+
if not kid:
|
|
123
|
+
raise ValueError("Response carries no kid; the signing key cannot be selected")
|
|
124
|
+
key = next((k for k in keys if isinstance(k, dict) and k.get("kid") == kid), None)
|
|
125
|
+
if key is None:
|
|
126
|
+
raise ValueError(f'{label} has no key matching kid "{kid}"')
|
|
127
|
+
if key.get("kty") != "EC" or key.get("crv") != "P-256":
|
|
128
|
+
raise ValueError(f'JWKS key "{kid}" is not a P-256 EC key; a classical signature cannot be verified with it')
|
|
129
|
+
return {"kty": "EC", "crv": "P-256", "x": str(key.get("x")), "y": str(key.get("y"))}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def select_jwks_key(options: Dict[str, Any], kid: Optional[str]) -> Tuple[Optional[Dict[str, str]], Optional[str]]:
|
|
133
|
+
"""Key selection as a verdict, not an escape.
|
|
134
|
+
|
|
135
|
+
When a key set is in play and the response's kid selects no usable key in
|
|
136
|
+
it, the failure belongs to the signature verdict alone: the other checks
|
|
137
|
+
need no key and are still performed. Returns ``(key, None)`` or
|
|
138
|
+
``(None, reason)``. Nothing is ever substituted.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
return fetch_jwks_key(options, kid), None
|
|
142
|
+
except Exception as e: # noqa: BLE001 (every failure is a reason, never an escape)
|
|
143
|
+
return None, f"JWKS fetch error: {e}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fetch_pq_key(options: Dict[str, Any], pq_kid: str) -> bytes:
|
|
147
|
+
"""The raw ML-DSA-65 public key the JWKS lists under ``pq_kid`` (RFC 9964 ``AKP``)."""
|
|
148
|
+
jwks, label = load_jwks(options)
|
|
149
|
+
keys = jwks.get("keys") or []
|
|
150
|
+
key = next((k for k in keys if isinstance(k, dict) and k.get("kid") == pq_kid), None)
|
|
151
|
+
if key is None:
|
|
152
|
+
raise ValueError(f'{label} has no key matching pqKid "{pq_kid}"')
|
|
153
|
+
if key.get("kty") != "AKP" or key.get("alg") != "ML-DSA-65" or not isinstance(key.get("pub"), str):
|
|
154
|
+
raise ValueError(f'JWKS key "{pq_kid}" is not an RFC 9964 ML-DSA-65 key')
|
|
155
|
+
return b64url_decode(key["pub"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── ECDSA P-256 ──────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ec_public_key(jwk: Dict[str, str]) -> ec.EllipticCurvePublicKey:
|
|
162
|
+
x = int.from_bytes(b64url_decode(jwk["x"]), "big")
|
|
163
|
+
y = int.from_bytes(b64url_decode(jwk["y"]), "big")
|
|
164
|
+
return ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _der_from_signature(sig: bytes, accept_der: bool) -> bytes:
|
|
168
|
+
if len(sig) == 64:
|
|
169
|
+
r = int.from_bytes(sig[:32], "big")
|
|
170
|
+
s = int.from_bytes(sig[32:], "big")
|
|
171
|
+
return encode_dss_signature(r, s)
|
|
172
|
+
if accept_der and sig[:1] == b"\x30":
|
|
173
|
+
r, s = decode_dss_signature(sig) # raises on malformed DER
|
|
174
|
+
return encode_dss_signature(r, s)
|
|
175
|
+
raise ValueError(f"signature is {len(sig)} bytes; expected 64 (P1363 r || s)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ecdsa_verify(jwk: Dict[str, str], signature: bytes, message: bytes, accept_der: bool = False) -> bool:
|
|
179
|
+
"""ECDSA P-256 / SHA-256 over ``message``.
|
|
180
|
+
|
|
181
|
+
``signature`` is raw P1363 ``r || s`` (what the API emits); on the JWT path
|
|
182
|
+
a DER-encoded signature is accepted too. Returns ``False`` for a signature
|
|
183
|
+
that does not verify and raises for a key or encoding that cannot be used.
|
|
184
|
+
"""
|
|
185
|
+
key = ec_public_key(jwk)
|
|
186
|
+
der = _der_from_signature(signature, accept_der)
|
|
187
|
+
try:
|
|
188
|
+
key.verify(der, message, ec.ECDSA(hashes.SHA256()))
|
|
189
|
+
return True
|
|
190
|
+
except InvalidSignature:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── ML-DSA-65 (post-quantum companion) ───────────────────────────────
|
|
195
|
+
|
|
196
|
+
_ml_dsa_loaded = False
|
|
197
|
+
_ml_dsa_verify: Optional[Callable[[bytes, bytes, bytes], bool]] = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_ml_dsa() -> Optional[Callable[[bytes, bytes, bytes], bool]]:
|
|
201
|
+
"""``verify(signature, message, public_key) -> bool`` for ML-DSA-65, or ``None``.
|
|
202
|
+
|
|
203
|
+
ML-DSA is not in the standard library, so verification uses ``dilithium-py``
|
|
204
|
+
when it is installed (``pip install insumer-verify[pq]``). Without it the
|
|
205
|
+
companion is reported ``unverifiable``: never a silent pass, never a silent
|
|
206
|
+
failure. FIPS 204 pure mode with an empty context, as the issuer signs.
|
|
207
|
+
"""
|
|
208
|
+
global _ml_dsa_loaded, _ml_dsa_verify
|
|
209
|
+
if _ml_dsa_loaded:
|
|
210
|
+
return _ml_dsa_verify
|
|
211
|
+
_ml_dsa_loaded = True
|
|
212
|
+
try:
|
|
213
|
+
from dilithium_py.ml_dsa import ML_DSA_65 # type: ignore[import-not-found]
|
|
214
|
+
except Exception: # noqa: BLE001 (any import problem means "not available")
|
|
215
|
+
_ml_dsa_verify = None
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def verify(signature: bytes, message: bytes, public_key: bytes) -> bool:
|
|
219
|
+
return bool(ML_DSA_65.verify(public_key, message, signature))
|
|
220
|
+
|
|
221
|
+
_ml_dsa_verify = verify
|
|
222
|
+
return verify
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
PQ_UNAVAILABLE_REASON = "ML-DSA verifier unavailable in this runtime (install dilithium-py, or insumer-verify[pq])"
|
insumer_verify/_time.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Timestamps the way JavaScript's ``Date`` reads them.
|
|
2
|
+
|
|
3
|
+
The verifier compares ISO 8601 strings from the issuer against the caller's
|
|
4
|
+
clock and the caller's own dates. ``Date.parse`` accepts a date-only form as
|
|
5
|
+
UTC and a date-time form without an offset as local time; this module mirrors
|
|
6
|
+
that so a cutoff a caller writes behaves the same in both verifiers. Anything
|
|
7
|
+
unparsable is ``None`` where JavaScript would have ``NaN``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import calendar
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from datetime import date, datetime, timezone
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
_ISO = re.compile(
|
|
19
|
+
r"^\s*(\d{4})-(\d{2})-(\d{2})"
|
|
20
|
+
r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?"
|
|
21
|
+
r"\s*(Z|z|[+-]\d{2}:?\d{2})?\s*$"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def now_ms() -> int:
|
|
26
|
+
"""Current wall-clock time in milliseconds since the Unix epoch."""
|
|
27
|
+
return int(time.time() * 1000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_time_ms(value: Any) -> Optional[int]:
|
|
31
|
+
"""Milliseconds since the epoch for an ISO 8601 string, a datetime, or a number.
|
|
32
|
+
|
|
33
|
+
Returns ``None`` when the value cannot be read as a time, which is what the
|
|
34
|
+
checks treat as JavaScript's ``NaN``.
|
|
35
|
+
"""
|
|
36
|
+
if value is None or isinstance(value, bool):
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, datetime):
|
|
39
|
+
return int(value.timestamp() * 1000)
|
|
40
|
+
if isinstance(value, date):
|
|
41
|
+
return int(calendar.timegm(value.timetuple()) * 1000)
|
|
42
|
+
if isinstance(value, (int, float)):
|
|
43
|
+
return int(value)
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
return None
|
|
46
|
+
m = _ISO.match(value)
|
|
47
|
+
if not m:
|
|
48
|
+
return None
|
|
49
|
+
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
50
|
+
has_time = m.group(4) is not None
|
|
51
|
+
hour = int(m.group(4) or 0)
|
|
52
|
+
minute = int(m.group(5) or 0)
|
|
53
|
+
second = int(m.group(6) or 0)
|
|
54
|
+
frac = m.group(7) or ""
|
|
55
|
+
millis = int((frac + "000")[:3]) if frac else 0
|
|
56
|
+
offset = m.group(8)
|
|
57
|
+
try:
|
|
58
|
+
if not has_time or offset is not None:
|
|
59
|
+
# Date-only is UTC; an explicit offset is applied.
|
|
60
|
+
base = calendar.timegm((year, month, day, hour, minute, second, 0, 0, 0))
|
|
61
|
+
if offset and offset not in ("Z", "z"):
|
|
62
|
+
sign = -1 if offset[0] == "-" else 1
|
|
63
|
+
digits = offset[1:].replace(":", "")
|
|
64
|
+
base -= sign * (int(digits[:2]) * 3600 + int(digits[2:]) * 60)
|
|
65
|
+
return base * 1000 + millis
|
|
66
|
+
# Date-time with no offset: local time, as Date.parse reads it.
|
|
67
|
+
local = datetime(year, month, day, hour, minute, second)
|
|
68
|
+
return int(local.timestamp() * 1000) + millis
|
|
69
|
+
except (ValueError, OverflowError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def iso_from_ms(ms: int) -> str:
|
|
74
|
+
"""``Date.prototype.toISOString`` for a millisecond timestamp."""
|
|
75
|
+
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
|
76
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
insumer_verify/py.typed
ADDED
|
File without changes
|