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.
@@ -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
@@ -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])"
@@ -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"
File without changes