idside 0.1.0.dev0__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.
idside/verify.py ADDED
@@ -0,0 +1,776 @@
1
+ # Patents: https://sdk.idside.eu/patents
2
+ # This software may be covered by the patents and applications listed there.
3
+
4
+ """Local, offline verification of ACT tokens.
5
+
6
+ An ACT token is the cryptographic proof ID Side issues for each AI call. This
7
+ module lets anyone holding a token — an auditor, a customer's compliance team,
8
+ a provider, a data protection authority — check it **without calling ID Side**:
9
+
10
+ from idside import verify_act
11
+
12
+ proof = verify_act(token, jwks=jwks, request_hash=request_hash)
13
+
14
+ Verification only. This package never signs and holds no private key (see
15
+ docs/concepts.md §6, "The SDK cannot forge one"): the ECDSA signing keys live
16
+ exclusively in the ID Side cloud. Everything here works from **public** key
17
+ material.
18
+
19
+ Every check fails closed. ``alg`` is pinned to ``ES256`` and ``typ`` to
20
+ ``act+jwt`` — a token declaring ``none`` or an HMAC algorithm is a forgery
21
+ attempt, not something to negotiate — and an unknown ``kid`` is never worked
22
+ around by trying another key.
23
+
24
+ Dependency decision
25
+ -------------------
26
+ ES256 (ECDSA P-256) verification is the one thing the standard library cannot
27
+ do. ``cryptography`` was **not** a dependency of this SDK (it appears in the
28
+ lockfile only transitively, behind the optional ``gemini`` extra), and the core
29
+ SDK — which sits on the AI request path of every user — must not grow a
30
+ mandatory C-extension dependency for a feature most callers never use. So:
31
+
32
+ * everything except the signature check (JWS structure, base64url, JSON, claim
33
+ types, time window, issuer, constant-time binding comparisons) is pure
34
+ standard library;
35
+ * the ECDSA check delegates to ``cryptography``, installed on demand with
36
+ ``pip install idside[verify]``, imported lazily, and raising an actionable
37
+ :class:`~idside.exceptions.ActVerificationUnavailableError` when absent.
38
+
39
+ Hand-rolling P-256 arithmetic was rejected: signature verification touches no
40
+ secret, so a pure-Python implementation would be side-channel-safe, but a
41
+ consent product cannot credibly ship bespoke curve code that auditors would
42
+ then have to review.
43
+
44
+ Spec: ``docs/spec/act-token.md``.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import base64
50
+ import binascii
51
+ import hmac
52
+ import json
53
+ import math
54
+ import re
55
+ import time
56
+ from collections.abc import Callable, Iterable, Mapping
57
+ from dataclasses import dataclass
58
+ from types import MappingProxyType
59
+ from typing import Any, Final, Literal
60
+
61
+ import httpx
62
+
63
+ from idside._transport import _USER_AGENT, DEFAULT_TIMEOUT_SECONDS
64
+ from idside.exceptions import (
65
+ ActAlgorithmError,
66
+ ActBindingMismatchError,
67
+ ActExpiredError,
68
+ ActIssuerError,
69
+ ActKeyNotFoundError,
70
+ ActMalformedError,
71
+ ActNotYetValidError,
72
+ ActPrivateKeyInKeySetError,
73
+ ActSignatureError,
74
+ ActTypeMismatchError,
75
+ ActVerificationUnavailableError,
76
+ )
77
+
78
+ __all__ = [
79
+ "ACT_ALGORITHM",
80
+ "ACT_TYPE",
81
+ "DEFAULT_LEEWAY_SECONDS",
82
+ "PRODUCTION_ISSUER",
83
+ "TEST_ISSUER",
84
+ "VerifiedAct",
85
+ "verify_act",
86
+ ]
87
+
88
+ #: The only algorithm an ACT token may declare. Pinned, never negotiated.
89
+ ACT_ALGORITHM: Final = "ES256"
90
+
91
+ #: The only ``typ`` an ACT token may declare (a UBT is ``ubt+jwt``).
92
+ ACT_TYPE: Final = "act+jwt"
93
+
94
+ #: Issuer of production proofs.
95
+ PRODUCTION_ISSUER: Final = "idside.eu"
96
+
97
+ #: Issuer of development proofs — accepted only on explicit opt-in.
98
+ TEST_ISSUER: Final = "test.idside.eu"
99
+
100
+ #: Clock-skew tolerance applied to ``exp``, ``iat`` and ``nbf``.
101
+ DEFAULT_LEEWAY_SECONDS: Final = 60.0
102
+
103
+ #: Maximum lifetime declared by an ACT, independent of clock-skew leeway.
104
+ _MAX_ACT_TTL_SECONDS: Final = 3_600
105
+
106
+ #: An ACT is at most 3,072 characters by design (the ``deviations`` claim is
107
+ #: what can take it past 2 KB); this cap only keeps absurd input from being
108
+ #: parsed.
109
+ #: Mirrors MAX_TOKEN_CHARS in the Node SDK.
110
+ MAX_TOKEN_CHARS: Final = 8_192
111
+
112
+ _COORDINATE_BYTES: Final = 32
113
+ _SIGNATURE_BYTES: Final = 2 * _COORDINATE_BYTES
114
+
115
+ #: Order of the P-256 base point: a valid ECDSA ``(r, s)`` lies in ``[1, n-1]``.
116
+ _P256_ORDER: Final = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
117
+
118
+ _B64URL_RE: Final = re.compile(r"[A-Za-z0-9_-]*")
119
+ _REQUEST_HASH_RE: Final = re.compile(r"sha256:[0-9a-f]{64}")
120
+
121
+ #: JWK members that hold private key material: ``d`` is the EC/OKP/RSA private
122
+ #: exponent or scalar, ``k`` a symmetric key. None of them belongs in a key set.
123
+ _PRIVATE_JWK_MEMBERS: Final = ("d", "k")
124
+
125
+ #: Claims every ACT token must carry, as non-empty strings (see spec §3).
126
+ _STRING_CLAIMS: Final = (
127
+ "iss",
128
+ "sub",
129
+ "app_id",
130
+ "jti",
131
+ "legal_basis",
132
+ "source",
133
+ "provider",
134
+ "model",
135
+ "request_hash",
136
+ "nonce",
137
+ "binding",
138
+ )
139
+
140
+ #: The same deadline as the authorize hop: verification is the same
141
+ #: fail-closed wait on the same kind of outage, so a caller does not wait a
142
+ #: different length of time depending on which of the two it is doing.
143
+ _JWKS_TIMEOUT: Final = httpx.Timeout(DEFAULT_TIMEOUT_SECONDS)
144
+
145
+ _MISSING_EXTRA = (
146
+ "ES256 verification needs the optional backend: "
147
+ "install it with `pip install idside[verify]` "
148
+ "(or `uv add 'idside[verify]'`). The token was NOT accepted."
149
+ )
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class VerifiedAct:
154
+ """A verified ACT token — what the proof actually attests.
155
+
156
+ Returned only when the signature, the token type, the algorithm, the issuer,
157
+ the time window and every binding the caller pinned have all checked out.
158
+ Its existence is the success signal; a failure is always an exception, never
159
+ a falsy return.
160
+
161
+ ``binding`` records how the end user was tied to the request — ``"ubt"``
162
+ (the ID Side widget bound the identity, the strong link) or ``"user_id"``
163
+ (the developer asserted it). It is the evidentiary weight of the proof.
164
+ """
165
+
166
+ issuer: str
167
+ subject: str
168
+ app_id: str
169
+ token_id: str
170
+ issued_at: int
171
+ expires_at: int
172
+ consent_state: Mapping[str, Any]
173
+ legal_basis: str
174
+ source: str
175
+ provider: str
176
+ model: str
177
+ request_hash: str
178
+ nonce: str
179
+ binding: str
180
+ mode: Literal["test", "live"] | None
181
+ key_id: str
182
+ claims: Mapping[str, Any]
183
+
184
+
185
+ def verify_act(
186
+ token: str,
187
+ *,
188
+ jwks: Mapping[str, Any] | None = None,
189
+ jwks_url: str | None = None,
190
+ issuer: str | Iterable[str] | None = None,
191
+ allow_test_issuer: bool = False,
192
+ allow_legacy_without_mode: bool = False,
193
+ leeway: float = DEFAULT_LEEWAY_SECONDS,
194
+ now: float | None = None,
195
+ request_hash: str | None = None,
196
+ nonce: str | None = None,
197
+ subject: str | None = None,
198
+ app_id: str | None = None,
199
+ ) -> VerifiedAct:
200
+ """Verify an ACT token locally and return its claims.
201
+
202
+ :param token: the raw ``X-AI-Consent-Token`` value (a three-part JWS).
203
+ :param jwks: a JWKS mapping (``{"keys": [...]}``) or a single JWK. Exactly
204
+ one of ``jwks`` / ``jwks_url`` is required — there is no ambient key
205
+ source and no default endpoint to fall back on.
206
+ :param jwks_url: an ``https://`` URL serving the JWKS (ID Side publishes it
207
+ at ``/.well-known/jwks.json``). Fetched on every call: for repeated
208
+ verification, fetch once yourself and pass ``jwks=`` instead.
209
+ :param issuer: accepted issuer, or several. Defaults to ``idside.eu`` only.
210
+ :param allow_test_issuer: also accept ``test.idside.eu``. Off by default so
211
+ a development token can never silently pass as a production proof.
212
+ :param allow_legacy_without_mode: accept proofs minted before the signed
213
+ ``mode`` claim existed. Off by default; when enabled, ``VerifiedAct.mode``
214
+ is ``None`` for such a proof. Invalid present values are always rejected.
215
+ :param leeway: clock-skew tolerance in seconds for ``exp``/``iat``/``nbf``.
216
+ :param now: verification time, as an epoch in seconds. Defaults to the real
217
+ clock. Set it to audit a past call — *"was this proof valid when the
218
+ call was made?"* — instead of catching :class:`ActExpiredError` on every
219
+ archived token. It moves **only** the time window: the signature, the
220
+ algorithm, the type, the issuer and the bindings are unaffected, so a
221
+ generous ``now`` can never turn a forgery into a proof.
222
+ :param request_hash: pin the request the token must be bound to
223
+ (``"sha256:<hex>"``, RFC 8785/JCS canonical — see ``idside._jcs``).
224
+ **One token = one request**: this is the check that makes a stolen but
225
+ perfectly signed token useless on another payload.
226
+ :param nonce: pin the token's anti-replay ``nonce``.
227
+ :param subject: pin the ``sub`` claim. Note that ``sub`` is the app-scoped
228
+ *pseudonym* the cloud derived from the end user — the raw ``user_id``
229
+ you passed to ``consent.wrap()`` deliberately never enters the token,
230
+ so it cannot be pinned here.
231
+ :param app_id: pin the application the call was authorized for.
232
+
233
+ :raises ActVerificationError: (or one of its subclasses) whenever the proof
234
+ does not check out. There is no "valid enough": every failure path
235
+ raises, and a caller that catches nothing is already fail-closed.
236
+ :raises ValueError: for caller mistakes — no key source, two key sources, a
237
+ non-HTTPS ``jwks_url``, a negative ``leeway``, a non-finite ``now``, an
238
+ empty issuer set.
239
+
240
+ Replay across *different* calls is not detectable offline (a verifier has
241
+ no history); it is the cloud ledger's job. What a local verifier proves is
242
+ that this signed token belongs to this exact request.
243
+ """
244
+ if (jwks is None) == (jwks_url is None):
245
+ raise ValueError(
246
+ "verify_act() needs exactly one key source: pass jwks= (a JWKS or a "
247
+ "single JWK) or jwks_url= (an https:// URL), not both and not neither."
248
+ )
249
+ if leeway < 0:
250
+ raise ValueError("verify_act() leeway must be zero or positive seconds.")
251
+ if now is not None and not math.isfinite(now):
252
+ raise ValueError("verify_act() now must be a finite epoch in seconds.")
253
+ verification_time = time.time() if now is None else now
254
+ expected_issuers = _expected_issuers(issuer, allow_test_issuer=allow_test_issuer)
255
+
256
+ header_segment, payload_segment, signature_segment = _split(token)
257
+ header = _json_object(
258
+ _b64url_decode(header_segment, what="JOSE header"), what="JOSE header"
259
+ )
260
+ _check_algorithm(header)
261
+ _check_type(header)
262
+ _check_no_critical_extensions(header)
263
+ key_id = _key_id(header)
264
+
265
+ # Decoded (not parsed) now: this validates the payload character set before
266
+ # it is fed to the signature check. Its content stays untrusted until then.
267
+ payload_bytes = _b64url_decode(payload_segment, what="payload")
268
+ signature = _b64url_decode(signature_segment, what="signature")
269
+
270
+ if jwks_url is not None:
271
+ key_set = _fetch_jwks(jwks_url)
272
+ elif jwks is not None:
273
+ key_set = jwks
274
+ else: # pragma: no cover - the XOR check above already rejected this
275
+ raise ValueError("verify_act() needs a jwks= or jwks_url= key source.")
276
+ jwk = _select_key(key_set, key_id)
277
+
278
+ _check_signature(
279
+ signing_input=f"{header_segment}.{payload_segment}".encode("ascii"),
280
+ signature=signature,
281
+ jwk=jwk,
282
+ )
283
+
284
+ # Claims are read only now: nothing above this line trusted the payload.
285
+ claims = _json_object(payload_bytes, what="payload")
286
+ strings = {name: _require_string(claims, name) for name in _STRING_CLAIMS}
287
+ issued_at = _require_timestamp(claims, "iat")
288
+ expires_at = _require_timestamp(claims, "exp")
289
+ consent_state = _require_object(claims, "consent_state")
290
+ if not _REQUEST_HASH_RE.fullmatch(strings["request_hash"]):
291
+ raise ActMalformedError(
292
+ "Claim 'request_hash' is not a canonical 'sha256:<64 hex chars>' "
293
+ "digest; the token does not follow the ACT specification."
294
+ )
295
+ if expires_at <= issued_at:
296
+ raise ActMalformedError(
297
+ "Claim 'exp' is not after 'iat': the token declares an empty or "
298
+ "inverted validity window."
299
+ )
300
+ if expires_at - issued_at > _MAX_ACT_TTL_SECONDS:
301
+ raise ActMalformedError(
302
+ "Claims 'iat' and 'exp' declare a validity window longer than "
303
+ "3,600 seconds; an ACT cannot live longer than one hour."
304
+ )
305
+ raw_mode = claims.get("mode")
306
+ mode: Literal["test", "live"] | None
307
+ if raw_mode is None and "mode" not in claims:
308
+ if not allow_legacy_without_mode:
309
+ raise ActMalformedError(
310
+ "Missing required claim 'mode'. For a proof minted before this "
311
+ "claim existed, pass allow_legacy_without_mode=True explicitly."
312
+ )
313
+ mode = None
314
+ elif raw_mode == "test":
315
+ mode = "test"
316
+ elif raw_mode == "live":
317
+ mode = "live"
318
+ else:
319
+ raise ActMalformedError("Claim 'mode' must be the string 'test' or 'live'.")
320
+
321
+ if strings["iss"] not in expected_issuers:
322
+ raise ActIssuerError(
323
+ f"Issuer {strings['iss']!r} is not accepted "
324
+ f"(expected {' or '.join(sorted(expected_issuers))}). "
325
+ "A token from another environment is not a proof for this one."
326
+ )
327
+ _check_time_window(
328
+ claims,
329
+ issued_at=issued_at,
330
+ expires_at=expires_at,
331
+ leeway=leeway,
332
+ now=verification_time,
333
+ )
334
+
335
+ for name, expected in (
336
+ ("request_hash", request_hash),
337
+ ("nonce", nonce),
338
+ ("sub", subject),
339
+ ("app_id", app_id),
340
+ ):
341
+ if expected is not None and not _constant_time_equal(strings[name], expected):
342
+ raise ActBindingMismatchError(
343
+ f"Claim {name!r} does not match the value pinned by the caller: "
344
+ "this proof was issued for a different request, user or app."
345
+ )
346
+
347
+ return VerifiedAct(
348
+ issuer=strings["iss"],
349
+ subject=strings["sub"],
350
+ app_id=strings["app_id"],
351
+ token_id=strings["jti"],
352
+ issued_at=issued_at,
353
+ expires_at=expires_at,
354
+ consent_state=MappingProxyType(dict(consent_state)),
355
+ legal_basis=strings["legal_basis"],
356
+ source=strings["source"],
357
+ provider=strings["provider"],
358
+ model=strings["model"],
359
+ request_hash=strings["request_hash"],
360
+ nonce=strings["nonce"],
361
+ binding=strings["binding"],
362
+ mode=mode,
363
+ key_id=key_id,
364
+ claims=MappingProxyType(dict(claims)),
365
+ )
366
+
367
+
368
+ # ── JWS structure ──────────────────────────────────────────────────────────
369
+
370
+
371
+ def _split(token: str) -> tuple[str, str, str]:
372
+ """Split a compact JWS into its three segments."""
373
+ if not isinstance(token, str):
374
+ raise ActMalformedError(
375
+ f"An ACT token is a string; got {type(token).__name__}."
376
+ )
377
+ # Node caps at the same figure. Without it, `verify_act("A" * 400_000_000 +
378
+ # ".eyJ9.x")` was split, regex-scanned, base64-decoded into ~300 MB and JSON
379
+ # parsed — all before the signature check. A verifier taking tokens from
380
+ # untrusted callers was memory-exhaustible, and the two SDKs disagreed on
381
+ # the same input, which the shared vectors exist to prevent.
382
+ if len(token) > MAX_TOKEN_CHARS:
383
+ # Word for word what Node says: the cap that refused it, and the bound
384
+ # the spec sets, which is not the cap.
385
+ raise ActMalformedError(
386
+ f"The token is implausibly large ({len(token)} characters, limit "
387
+ f"{MAX_TOKEN_CHARS}); an ACT token is at most 3,072 characters. "
388
+ "Refusing to parse it."
389
+ )
390
+ segments = token.split(".")
391
+ if len(segments) != 3 or not all(segments[:2]):
392
+ raise ActMalformedError(
393
+ f"Not a compact JWS: expected 3 non-empty segments, got {len(segments)}. "
394
+ "Pass the raw X-AI-Consent-Token value, untruncated."
395
+ )
396
+ return segments[0], segments[1], segments[2]
397
+
398
+
399
+ def _b64url_decode(segment: str, *, what: str) -> bytes:
400
+ """Decode one JWS segment. Strict: unpadded base64url only, and canonical.
401
+
402
+ A segment a compliant encoder would not have produced is a rejection, never
403
+ something to clean up. The last character of a segment whose length is not a
404
+ multiple of 4 carries bits that encode nothing; Python's decoder ignores
405
+ them, so without this check one byte string would have several spellings —
406
+ and a defect in the *encoding* would surface later as a signature failure,
407
+ which names the wrong culprit and pays for a curve operation to say so.
408
+ """
409
+ if not _B64URL_RE.fullmatch(segment):
410
+ raise ActMalformedError(
411
+ f"The {what} is not unpadded base64url (JOSE forbids '=', '+' and '/')."
412
+ )
413
+ try:
414
+ decoded = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
415
+ except (binascii.Error, ValueError) as exc:
416
+ raise ActMalformedError(f"The {what} is not decodable base64url.") from exc
417
+ if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != segment:
418
+ raise ActMalformedError(
419
+ f"The {what} is not canonical base64url: it carries unused trailing bits."
420
+ )
421
+ return decoded
422
+
423
+
424
+ def _json_object(raw: bytes, *, what: str) -> Mapping[str, Any]:
425
+ """Parse a JWS segment as a JSON object."""
426
+ try:
427
+ value = json.loads(raw)
428
+ except ValueError as exc:
429
+ raise ActMalformedError(f"The {what} is not valid JSON.") from exc
430
+ if not isinstance(value, dict):
431
+ raise ActMalformedError(f"The {what} is not a JSON object.")
432
+ return value
433
+
434
+
435
+ # ── header checks ──────────────────────────────────────────────────────────
436
+
437
+
438
+ def _check_algorithm(header: Mapping[str, Any]) -> None:
439
+ """Pin ``alg`` to ES256 — the defence against algorithm confusion."""
440
+ algorithm = header.get("alg")
441
+ if algorithm != ACT_ALGORITHM:
442
+ raise ActAlgorithmError(
443
+ f"Refusing algorithm {algorithm!r}: an ACT token is always "
444
+ f"{ACT_ALGORITHM}. A token declaring anything else — 'none', an HMAC "
445
+ "algorithm, another curve — is a forgery attempt, not a negotiation."
446
+ )
447
+
448
+
449
+ def _check_type(header: Mapping[str, Any]) -> None:
450
+ """Pin ``typ`` to ``act+jwt`` — no other ID Side token stands in for a proof."""
451
+ token_type = header.get("typ")
452
+ if token_type != ACT_TYPE:
453
+ raise ActTypeMismatchError(
454
+ f"Refusing token type {token_type!r}: expected {ACT_TYPE!r}. "
455
+ "A User Binding Token ('ubt+jwt') or any other ID Side token is not "
456
+ "a proof that an AI call was authorized."
457
+ )
458
+
459
+
460
+ def _check_no_critical_extensions(header: Mapping[str, Any]) -> None:
461
+ """Reject ``crit``: this verifier understands no JOSE extension."""
462
+ if "crit" in header:
463
+ raise ActMalformedError(
464
+ "The JOSE header declares 'crit' extensions this verifier does not "
465
+ "understand; RFC 7515 requires rejecting the token."
466
+ )
467
+
468
+
469
+ def _key_id(header: Mapping[str, Any]) -> str:
470
+ """Read ``kid``. Absent means no key selection is possible — fail closed."""
471
+ key_id = header.get("kid")
472
+ if not isinstance(key_id, str) or not key_id:
473
+ raise ActMalformedError(
474
+ "The JOSE header carries no 'kid', so no verification key can be "
475
+ "selected. ID Side always names the signing key."
476
+ )
477
+ return key_id
478
+
479
+
480
+ # ── key material ───────────────────────────────────────────────────────────
481
+
482
+
483
+ def _jwks_http_client() -> httpx.Client:
484
+ """Build the client used to fetch a JWKS (seam for tests)."""
485
+ return httpx.Client(timeout=_JWKS_TIMEOUT, follow_redirects=False)
486
+
487
+
488
+ def _fetch_jwks(url: str) -> Mapping[str, Any]:
489
+ """Fetch a JWKS over HTTPS. Anything unusable fails closed."""
490
+ if not url.lower().startswith("https://"):
491
+ raise ValueError(
492
+ "verify_act() only fetches a JWKS over https:// — public keys "
493
+ f"retrieved over an unauthenticated channel prove nothing; got {url!r}."
494
+ )
495
+ try:
496
+ with _jwks_http_client() as client:
497
+ response = client.get(url, headers={"User-Agent": _USER_AGENT})
498
+ except httpx.HTTPError as exc:
499
+ raise ActVerificationUnavailableError(
500
+ f"The JWKS at {url} is unreachable, so the token could not be "
501
+ "verified; it was NOT accepted."
502
+ ) from exc
503
+ if response.status_code != httpx.codes.OK:
504
+ raise ActVerificationUnavailableError(
505
+ f"The JWKS at {url} answered HTTP {response.status_code}; the token "
506
+ "could not be verified and was NOT accepted."
507
+ )
508
+ try:
509
+ document = response.json()
510
+ except ValueError as exc:
511
+ raise ActVerificationUnavailableError(
512
+ f"The JWKS at {url} is not valid JSON; the token was NOT accepted."
513
+ ) from exc
514
+ if not isinstance(document, dict):
515
+ raise ActVerificationUnavailableError(
516
+ f"The JWKS at {url} is not a JSON object; the token was NOT accepted."
517
+ )
518
+ return document
519
+
520
+
521
+ def _select_key(key_set: Any, key_id: str) -> Mapping[str, Any]:
522
+ """Pick the JWK named by ``kid``. No fallback: unknown key ⇒ rejection."""
523
+ # `verify_act(token, jwks="path/to/keys.json")` is a plausible mistake, and
524
+ # it used to escape the typed hierarchy entirely: `"keys" in key_set` is a
525
+ # SUBSTRING test on a str, so it passed, and `key_set["keys"]` then raised a
526
+ # bare TypeError past every `except ActVerificationError` a fail-closed
527
+ # caller had written. Node rejects both shapes here; so does this now.
528
+ if not isinstance(key_set, Mapping):
529
+ raise ActVerificationUnavailableError(
530
+ "The key set is neither a JWKS object ({'keys': [...]}) nor a single JWK; "
531
+ f"got {type(key_set).__name__}. The token was NOT accepted."
532
+ )
533
+ if "keys" in key_set:
534
+ keys = key_set["keys"]
535
+ if not isinstance(keys, list):
536
+ raise ActVerificationUnavailableError(
537
+ "The JWKS 'keys' member is not an array; the token was NOT accepted."
538
+ )
539
+ else:
540
+ keys = [key_set] # a bare JWK is accepted as a one-key set
541
+ matches = [
542
+ key for key in keys if isinstance(key, Mapping) and key.get("kid") == key_id
543
+ ]
544
+ if not matches:
545
+ raise ActKeyNotFoundError(
546
+ f"No key with kid={key_id!r} in the supplied key set. The signing key "
547
+ "may have been rotated: refresh the JWKS from /.well-known/jwks.json. "
548
+ "No other key was tried."
549
+ )
550
+ if len(matches) > 1:
551
+ raise ActVerificationUnavailableError(
552
+ f"The key set is ambiguous: {len(matches)} keys share kid={key_id!r}. "
553
+ "Verification needs one unmistakable key; the token was NOT accepted."
554
+ )
555
+ return _check_key_usable(matches[0], key_id)
556
+
557
+
558
+ def _check_key_usable(jwk: Mapping[str, Any], key_id: str) -> Mapping[str, Any]:
559
+ """Reject a matching JWK that cannot verify an ES256 signature.
560
+
561
+ Private key material is checked first, before anything else that may be
562
+ wrong with the key: when someone's signing key is sitting in a key set, that
563
+ is the headline, not the fact that it also has the wrong curve.
564
+ """
565
+ leaked = next((member for member in _PRIVATE_JWK_MEMBERS if member in jwk), None)
566
+ if leaked is not None:
567
+ raise ActPrivateKeyInKeySetError(
568
+ f"The key with kid={key_id!r} cannot verify an ACT signature: it "
569
+ f"carries private key material ({leaked!r}), which never belongs in "
570
+ "a key set. A key set is public by definition, so this is either a "
571
+ "signing key published by mistake or a poisoned key set: remove the "
572
+ "private members from the JWKS and rotate the key. The token was "
573
+ "NOT accepted."
574
+ )
575
+ reason: str | None = None
576
+ if jwk.get("kty") != "EC":
577
+ reason = f"key type {jwk.get('kty')!r} is not 'EC'"
578
+ elif jwk.get("crv") != "P-256":
579
+ reason = f"curve {jwk.get('crv')!r} is not 'P-256'"
580
+ # `jwk.get(x)` cannot tell an absent member from an explicit JSON null, and
581
+ # the two SDKs disagreed because of it: `{"alg": null}` was accepted here and
582
+ # refused by Node. Absence is permissive, a stated null is not.
583
+ elif "alg" in jwk and jwk["alg"] != ACT_ALGORITHM:
584
+ reason = f"the key declares alg {jwk.get('alg')!r}"
585
+ elif "use" in jwk and jwk["use"] != "sig":
586
+ reason = f"the key is marked for {jwk.get('use')!r}, not signatures"
587
+ elif "key_ops" in jwk and (
588
+ not isinstance(key_ops := jwk["key_ops"], list) or "verify" not in key_ops
589
+ ):
590
+ reason = "the key does not allow the 'verify' operation"
591
+ if reason is not None:
592
+ raise ActKeyNotFoundError(
593
+ f"The key with kid={key_id!r} cannot verify an ACT signature: {reason}."
594
+ )
595
+ return jwk
596
+
597
+
598
+ def _coordinate(jwk: Mapping[str, Any], name: str, key_id: str) -> int:
599
+ """Decode a P-256 affine coordinate from its fixed-width JWK member."""
600
+ value = jwk.get(name)
601
+ if not isinstance(value, str):
602
+ raise ActKeyNotFoundError(
603
+ f"The key with kid={key_id!r} has no {name!r} coordinate."
604
+ )
605
+ try:
606
+ raw = _b64url_decode(value, what=f"key coordinate {name!r}")
607
+ except ActMalformedError as exc:
608
+ raise ActKeyNotFoundError(
609
+ f"The key with kid={key_id!r} has an undecodable {name!r} coordinate."
610
+ ) from exc
611
+ if len(raw) != _COORDINATE_BYTES:
612
+ raise ActKeyNotFoundError(
613
+ f"The key with kid={key_id!r} has a {len(raw)}-byte {name!r} "
614
+ f"coordinate; P-256 requires exactly {_COORDINATE_BYTES}."
615
+ )
616
+ return int.from_bytes(raw, "big")
617
+
618
+
619
+ # ── signature ──────────────────────────────────────────────────────────────
620
+
621
+
622
+ def _load_es256_backend() -> Callable[[bytes, bytes, int, int], bool]:
623
+ """Import the optional ES256 backend and return a verification callable.
624
+
625
+ Imported lazily and isolated in one function: callers who never verify a
626
+ token do not pay for ``cryptography``, and tests can simulate the extra
627
+ being absent. Raises ``ImportError`` when ``idside[verify]`` is missing.
628
+ """
629
+ from cryptography.exceptions import InvalidSignature
630
+ from cryptography.hazmat.primitives import hashes
631
+ from cryptography.hazmat.primitives.asymmetric import ec
632
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
633
+
634
+ def verify(signing_input: bytes, signature: bytes, x: int, y: int) -> bool:
635
+ r = int.from_bytes(signature[:_COORDINATE_BYTES], "big")
636
+ s = int.from_bytes(signature[_COORDINATE_BYTES:], "big")
637
+ if not (0 < r < _P256_ORDER and 0 < s < _P256_ORDER):
638
+ return False # outside [1, n-1]: not an ECDSA signature at all
639
+ try:
640
+ public_key = ec.EllipticCurvePublicNumbers(
641
+ x, y, ec.SECP256R1()
642
+ ).public_key()
643
+ except ValueError as exc:
644
+ raise ActKeyNotFoundError(
645
+ "The public key in the JWKS is not a valid P-256 point."
646
+ ) from exc
647
+ try:
648
+ public_key.verify(
649
+ encode_dss_signature(r, s),
650
+ signing_input,
651
+ ec.ECDSA(hashes.SHA256()),
652
+ )
653
+ except InvalidSignature:
654
+ return False
655
+ return True
656
+
657
+ return verify
658
+
659
+
660
+ def _check_signature(
661
+ *, signing_input: bytes, signature: bytes, jwk: Mapping[str, Any]
662
+ ) -> None:
663
+ """Verify the ES256 signature over ``header.payload``."""
664
+ try:
665
+ verify = _load_es256_backend()
666
+ except ImportError as exc:
667
+ raise ActVerificationUnavailableError(_MISSING_EXTRA) from exc
668
+ if len(signature) != _SIGNATURE_BYTES:
669
+ raise ActSignatureError(
670
+ f"An ES256 signature is {_SIGNATURE_BYTES} bytes (r||s); this token "
671
+ f"carries {len(signature)}."
672
+ )
673
+ key_id = str(jwk.get("kid"))
674
+ if not verify(
675
+ signing_input,
676
+ signature,
677
+ _coordinate(jwk, "x", key_id),
678
+ _coordinate(jwk, "y", key_id),
679
+ ):
680
+ raise ActSignatureError(
681
+ f"The signature does not match the token's header and payload under "
682
+ f"the key kid={key_id!r}: the token was altered after signing, or was "
683
+ "never signed by ID Side."
684
+ )
685
+
686
+
687
+ # ── claims ─────────────────────────────────────────────────────────────────
688
+
689
+
690
+ def _require_string(claims: Mapping[str, Any], name: str) -> str:
691
+ value = _require_present(claims, name)
692
+ if not isinstance(value, str) or not value:
693
+ raise ActMalformedError(
694
+ f"Claim {name!r} must be a non-empty string; got {value!r}."
695
+ )
696
+ return value
697
+
698
+
699
+ def _require_timestamp(claims: Mapping[str, Any], name: str) -> int:
700
+ value = _require_present(claims, name)
701
+ # bool is an int subclass — True is not a point in time.
702
+ if not isinstance(value, int) or isinstance(value, bool):
703
+ raise ActMalformedError(
704
+ f"Claim {name!r} must be an integer epoch timestamp; got {value!r}."
705
+ )
706
+ return value
707
+
708
+
709
+ def _require_object(claims: Mapping[str, Any], name: str) -> Mapping[str, Any]:
710
+ value = _require_present(claims, name)
711
+ if not isinstance(value, dict):
712
+ raise ActMalformedError(f"Claim {name!r} must be a JSON object.")
713
+ return value
714
+
715
+
716
+ def _require_present(claims: Mapping[str, Any], name: str) -> Any:
717
+ if name not in claims:
718
+ raise ActMalformedError(
719
+ f"The token carries no {name!r} claim, so it is not a complete ACT "
720
+ "token (see docs/spec/act-token.md §3)."
721
+ )
722
+ return claims[name]
723
+
724
+
725
+ def _check_time_window(
726
+ claims: Mapping[str, Any],
727
+ *,
728
+ issued_at: int,
729
+ expires_at: int,
730
+ leeway: float,
731
+ now: float,
732
+ ) -> None:
733
+ """Enforce ``exp``, ``iat`` and — when present — ``nbf`` as of ``now``."""
734
+ if now > expires_at + leeway:
735
+ raise ActExpiredError(
736
+ f"The token expired at epoch {expires_at} "
737
+ f"({int(now - expires_at)} s ago): it proves nothing about a call "
738
+ "made now. ACT tokens live one hour."
739
+ )
740
+ if now < issued_at - leeway:
741
+ raise ActNotYetValidError(
742
+ f"Claim 'iat' is {int(issued_at - now)} s in the future. Check this "
743
+ "machine's clock before suspecting the token."
744
+ )
745
+ if "nbf" in claims:
746
+ not_before = _require_timestamp(claims, "nbf")
747
+ if now < not_before - leeway:
748
+ raise ActNotYetValidError(
749
+ f"Claim 'nbf' is {int(not_before - now)} s in the future: the "
750
+ "token is not valid yet."
751
+ )
752
+
753
+
754
+ def _expected_issuers(
755
+ issuer: str | Iterable[str] | None, *, allow_test_issuer: bool
756
+ ) -> frozenset[str]:
757
+ """Resolve the accepted issuers; the test issuer needs an explicit opt-in."""
758
+ if issuer is None:
759
+ expected = {PRODUCTION_ISSUER}
760
+ elif isinstance(issuer, str):
761
+ expected = {issuer}
762
+ else:
763
+ expected = set(issuer)
764
+ if allow_test_issuer:
765
+ expected.add(TEST_ISSUER)
766
+ if not expected or not all(expected):
767
+ raise ValueError(
768
+ "verify_act() needs at least one non-empty expected issuer; "
769
+ "an empty issuer= would accept a token from anywhere."
770
+ )
771
+ return frozenset(expected)
772
+
773
+
774
+ def _constant_time_equal(claimed: str, expected: str) -> bool:
775
+ """Compare two claim values without leaking a match position by timing."""
776
+ return hmac.compare_digest(claimed.encode("utf-8"), expected.encode("utf-8"))