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/__about__.py +5 -0
- idside/__init__.py +158 -0
- idside/_destination.py +165 -0
- idside/_guard.py +573 -0
- idside/_jcs.py +130 -0
- idside/_log.py +163 -0
- idside/_routing.py +280 -0
- idside/_transport.py +831 -0
- idside/adapters/__init__.py +110 -0
- idside/adapters/_call_options.py +859 -0
- idside/adapters/base.py +462 -0
- idside/adapters/gemini.py +89 -0
- idside/consent-options.json +207 -0
- idside/consent.py +1580 -0
- idside/exceptions.py +481 -0
- idside/options.py +297 -0
- idside/providers/__init__.py +252 -0
- idside/providers/anthropic.json +108 -0
- idside/providers/deepseek.json +108 -0
- idside/providers/gemini.json +130 -0
- idside/providers/mistral.json +195 -0
- idside/providers/openai.json +217 -0
- idside/py.typed +0 -0
- idside/verify.py +776 -0
- idside-0.1.0.dev0.dist-info/METADATA +473 -0
- idside-0.1.0.dev0.dist-info/RECORD +29 -0
- idside-0.1.0.dev0.dist-info/WHEEL +4 -0
- idside-0.1.0.dev0.dist-info/licenses/LICENSE +350 -0
- idside-0.1.0.dev0.dist-info/licenses/NOTICE +29 -0
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"))
|