regent-httpsig 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
regent_httpsig/verify.py CHANGED
@@ -10,23 +10,35 @@ Two schemes are accepted side by side (both ride the same ``Signature`` /
10
10
  traffic this way today. Both wire forms of ``Signature-Agent`` are accepted:
11
11
  the draft -05 sf-dictionary (covered with ``;key=``) and the legacy bare
12
12
  sf-string OpenAI ships.
13
- - **AAuth** (draft-hardt-oauth-aauth-protocol, identity-based mode): the agent
14
- carries a JWT ``agent_token`` (``typ: aa-agent+jwt``) in the ``Signature-Key``
15
- header; the token's issuer JWKS (``{iss}/.well-known/aauth-agent.json`` →
16
- ``jwks_uri``) verifies the token, and the token's ``cnf.jwk`` verifies the
17
- request signature (proof of possession). Requires the ``[aauth]`` extra.
13
+ - **AAuth** (draft-hardt-oauth-aauth-protocol-11): the agent carries a JWT in
14
+ the ``Signature-Key`` header under the ``jwt`` scheme — an agent token
15
+ (``typ: aa-agent+jwt``), a person token (``aa-person+jwt``) or an auth token
16
+ (``aa-auth+jwt``, the budget carrier). The issuer's JWKS, discovered through
17
+ ``{iss}/.well-known/{dwk}``, verifies the token; the token's ``cnf.jwk``
18
+ verifies the request signature (proof of possession). Servers (a PS or AS
19
+ calling a resource's usage or revocation endpoint) sign under the ``jwks_uri``
20
+ scheme with ``id``, ``dwk`` and ``kid``. Requires the ``[aauth]`` extra.
21
+
22
+ Failures are typed, not silent: :meth:`HttpsigVerifier.verify_detailed` returns
23
+ a :class:`VerificationError` carrying the ``Signature-Error`` code of
24
+ draft-hardt-httpbis-signature-key (``invalid_signature``, ``clock_skew``,
25
+ ``expired_jwt``, ``revoked_jwt``, ``unsupported_scheme``, …) exactly as -11
26
+ §11.3.4 assigns them, so a resource can answer ``401`` with the header the
27
+ agent needs. :meth:`HttpsigVerifier.verify` keeps the old ``None``-on-failure
28
+ contract for callers that only want a yes or no.
18
29
 
19
30
  Directory fetches are SSRF-guarded (https-only, public-IP-only, size-capped,
20
- no redirects) and cached per verifier instance. A bad or missing signature
21
- yields ``None`` — verification failure is a result, not an exception.
31
+ no redirects) and cached per verifier instance.
22
32
  """
23
33
 
24
34
  from __future__ import annotations
25
35
 
26
36
  import asyncio
37
+ import base64
38
+ import hashlib
27
39
  import logging
28
40
  import time
29
- from collections.abc import Mapping
41
+ from collections.abc import Awaitable, Callable, Mapping
30
42
  from dataclasses import dataclass, field
31
43
  from datetime import timedelta
32
44
  from typing import Any
@@ -45,10 +57,18 @@ from regent_httpsig.sfv import (
45
57
  Message,
46
58
  StaticKeyResolver,
47
59
  parse_signature_agent,
48
- parse_signature_key_header,
60
+ parse_signature_input,
61
+ parse_signature_key,
49
62
  )
50
63
 
51
- __all__ = ["HttpsigVerifier", "VerifiedSignature", "WBA_TAG"]
64
+ __all__ = [
65
+ "AAUTH_ACCEPT_ALGS",
66
+ "AAUTH_ACCEPT_SCHEMES",
67
+ "HttpsigVerifier",
68
+ "VerificationError",
69
+ "VerifiedSignature",
70
+ "WBA_TAG",
71
+ ]
52
72
 
53
73
  logger = logging.getLogger("regent_httpsig")
54
74
 
@@ -58,13 +78,23 @@ AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
58
78
  AAUTH_PERSON_METADATA_PATH = "/.well-known/aauth-person.json"
59
79
  AAUTH_JWT_TYP = "aa-agent+jwt"
60
80
  AAUTH_PERSON_TYP = "aa-person+jwt"
61
- AAUTH_AUTH_TYP = "aa-auth+jwt" # PS-issued auth tokens — the budget carrier
62
- # -11: person and auth tokens live at most one hour — enforced with tolerance.
63
- PERSON_TOKEN_MAX_LIFETIME = 3600 + 90
64
- # How long after its exp an auth token still earns the budgets "final record"
65
- # challenge (opt-in, BudgetMiddleware only): the meter retains consumption for
66
- # about this long after last activity; beyond it there is no figure to carry.
67
- EXPIRED_AUTH_TOKEN_GRACE = 7200
81
+ AAUTH_AUTH_TYP = "aa-auth+jwt" # PS/AS-issued auth tokens — the budget carrier
82
+ # The well-known documents a server may sign under (§11.3.2).
83
+ SERVER_DWKS = ("aauth-person.json", "aauth-access.json", "aauth-agent.json", "aauth-resource.json")
84
+ # -11 §11.5.2: person and auth tokens live at most one hour. No tolerance.
85
+ PERSON_TOKEN_MAX_LIFETIME = 3600
86
+ # What this verifier accepts, as advertised on Signature-Error responses.
87
+ AAUTH_ACCEPT_SCHEMES: tuple[str, ...] = ("jwt", "jwks_uri")
88
+ AAUTH_ACCEPT_ALGS: tuple[str, ...] = ("Ed25519", "ES256", "RS256")
89
+
90
+ # JOSE alg → (RFC 9421 signature algorithm, key type, curve)
91
+ _ALG_TABLE: dict[str, tuple[str, str, str | None]] = {
92
+ "Ed25519": ("ED25519", "OKP", "Ed25519"),
93
+ "ES256": ("ECDSA_P256_SHA256", "EC", "P-256"),
94
+ "RS256": ("RSA_V1_5_SHA256", "RSA", None),
95
+ }
96
+
97
+ RevocationCheck = Callable[[str, str], Awaitable[bool]]
68
98
 
69
99
 
70
100
  def _register_fully_specified_algs() -> None:
@@ -81,20 +111,15 @@ def _register_fully_specified_algs() -> None:
81
111
 
82
112
  @dataclass
83
113
  class VerifiedSignature:
84
- """A successfully verified inbound agent signature."""
114
+ """A successfully verified inbound signature."""
85
115
 
86
- scheme: str # "web-bot-auth" | "aauth"
87
- agent: str # WBA: Signature-Agent origin; AAuth: the token issuer
88
- keyid: str # RFC 7638 / RFC 8037 A.3 JWK thumbprint
116
+ scheme: str # "web-bot-auth" | "aauth" | "aauth-person" | "aauth-auth" | "aauth-server"
117
+ agent: str # WBA: Signature-Agent origin; AAuth: the token issuer / the server id
118
+ keyid: str # RFC 7638 / RFC 8037 A.3 JWK thumbprint (server scheme: the JWKS kid)
89
119
  trusted: bool # agent/issuer is on the configured trust list
90
120
  sub: str | None = None # AAuth agent id (token `sub`)
91
121
  label: str = ""
92
122
  claims: dict[str, Any] = field(default_factory=dict) # AAuth token claims (redacted)
93
- # Set only when ``verify(..., allow_expired_auth_token=True)`` accepted an
94
- # auth token PAST its exp: genuine (issuer signature + proof of possession
95
- # verified) but NOT valid for access. BudgetMiddleware uses it to answer
96
- # with the final-record challenge; nothing else should ever see it True.
97
- expired: bool = False
98
123
 
99
124
  def context(self) -> dict[str, Any]:
100
125
  """A flat dict suitable for logging / policy engines / audit trails."""
@@ -110,11 +135,46 @@ class VerifiedSignature:
110
135
  return out
111
136
 
112
137
 
138
+ @dataclass
139
+ class VerificationError:
140
+ """Why a signed request was refused — the ``Signature-Error`` vocabulary of
141
+ draft-hardt-httpbis-signature-key, assigned as AAuth -11 §11.3.4 assigns it.
142
+ ``headers()`` is what a 401 carries; ``problem()`` is the RFC 9457 body."""
143
+
144
+ code: str
145
+ detail: str = ""
146
+ accept_schemes: tuple[str, ...] = ()
147
+ accept_algs: tuple[str, ...] = ()
148
+ required_input: tuple[str, ...] = ()
149
+ token_typ: str | None = None # the AAuth token typ, once known (jwt scheme only)
150
+
151
+ def headers(self) -> dict[str, str]:
152
+ value = f"error={self.code}"
153
+ if self.required_input:
154
+ inner = " ".join(f'"{c}"' for c in self.required_input)
155
+ value += f", required_input=({inner})"
156
+ out = {"Signature-Error": value}
157
+ if self.accept_schemes:
158
+ out["Accept-Signature-Scheme"] = ", ".join(self.accept_schemes)
159
+ if self.accept_algs:
160
+ out["Accept-Signature-Alg"] = ", ".join(self.accept_algs)
161
+ return out
162
+
163
+ def problem(self, status: int = 401) -> dict[str, Any]:
164
+ return {
165
+ "type": f"urn:ietf:params:sig-error:{self.code}",
166
+ "title": self.code.replace("_", " "),
167
+ "status": status,
168
+ "detail": self.detail or self.code,
169
+ "error": self.code,
170
+ }
171
+
172
+
113
173
  class _KeyidOptionalParams(dict): # type: ignore[type-arg]
114
174
  """RFC 9421 makes ``keyid`` OPTIONAL, but the upstream verifier reads
115
175
  ``params["keyid"]`` unconditionally. On the AAuth path the key comes from
116
- the token's ``cnf.jwk``, so conforming signers (e.g. aauth-signing) omit
117
- keyid entirely. Returning None for a missing keyid routes resolution to our
176
+ the Signature-Key header, so conforming signers omit keyid (§11.3.3.2).
177
+ Returning None for a missing keyid routes resolution to our
118
178
  StaticKeyResolver default WITHOUT adding the key to the params — iteration
119
179
  is unchanged, so the reconstructed signature base stays byte-identical."""
120
180
 
@@ -125,6 +185,11 @@ class _KeyidOptionalParams(dict): # type: ignore[type-arg]
125
185
 
126
186
 
127
187
  class _KeyidOptionalVerifier(HTTPMessageVerifier):
188
+ def validate_created_and_expires(self, sig_input: Any, max_age: Any = None) -> None:
189
+ """No-op: the -11 window (`created` ± signature_window, `expires`) was
190
+ judged in ``_check_signature_input`` with the draft's own error codes;
191
+ the upstream 5-second skew rule must not pre-empt them."""
192
+
128
193
  def _verify_one(self, *, label: Any, sig_input: Any, signature: Any,
129
194
  message: Any, max_age: Any) -> Any:
130
195
  if "keyid" not in sig_input.params:
@@ -145,12 +210,51 @@ def _keys_from_jwks(doc: dict[str, Any]) -> dict[str, Ed25519PublicKey]:
145
210
  return keys
146
211
 
147
212
 
213
+ class _Refused(Exception):
214
+ """Internal: carries a VerificationError out of the AAuth path."""
215
+
216
+ def __init__(self, error: VerificationError) -> None:
217
+ super().__init__(error.code)
218
+ self.error = error
219
+
220
+
221
+ def _refuse(code: str, detail: str = "", **extra: Any) -> _Refused:
222
+ return _Refused(VerificationError(code, detail, **extra))
223
+
224
+
225
+ def _load_public_key(jwk: dict[str, Any]) -> tuple[Any, Any]:
226
+ """Return ``(public key object, http_message_signatures algorithm)`` for a JWK
227
+ whose ``alg`` is fully specified. Raises _Refused with the -11 code."""
228
+ from http_message_signatures import algorithms as hms_algs
229
+
230
+ alg = jwk.get("alg")
231
+ if not isinstance(alg, str) or alg not in _ALG_TABLE:
232
+ raise _refuse("unsupported_algorithm",
233
+ f"key alg {alg!r} is absent, polymorphic or not implemented",
234
+ accept_algs=AAUTH_ACCEPT_ALGS)
235
+ hms_name, kty, crv = _ALG_TABLE[alg]
236
+ if jwk.get("kty") != kty or (crv is not None and jwk.get("crv") != crv):
237
+ raise _refuse("invalid_key", f"key type disagrees with alg {alg}")
238
+ try:
239
+ if alg == "Ed25519":
240
+ key: Any = load_ed25519_jwk(jwk)
241
+ else:
242
+ import jwt as pyjwt
243
+
244
+ key = pyjwt.PyJWK({k: v for k, v in jwk.items() if k != "alg"}).key
245
+ except Exception as exc: # noqa: BLE001
246
+ raise _refuse("invalid_key", f"cannot parse key: {str(exc)[:80]}") from exc
247
+ return key, getattr(hms_algs, hms_name)
248
+
249
+
148
250
  class HttpsigVerifier:
149
251
  """Verify RFC 9421-signed agent requests (Web Bot Auth + AAuth).
150
252
 
151
253
  Instances are cheap and hold their own directory cache; create one per
152
254
  application and reuse it. ``http_client`` is optional — pass your app's
153
- shared :class:`httpx.AsyncClient` to reuse its pool.
255
+ shared :class:`httpx.AsyncClient` to reuse its pool. ``is_revoked(iss,
256
+ jti)`` is how the application tells the verifier about revocations it has
257
+ received (§11.12.5): a token it says yes for is answered ``revoked_jwt``.
154
258
 
155
259
  Usage::
156
260
 
@@ -165,47 +269,82 @@ class HttpsigVerifier:
165
269
  config: HttpsigConfig | None = None,
166
270
  *,
167
271
  http_client: httpx.AsyncClient | None = None,
272
+ is_revoked: RevocationCheck | None = None,
168
273
  ) -> None:
169
274
  self.config = config or HttpsigConfig()
170
275
  self._http = http_client
171
276
  self._owns_client = http_client is None
277
+ self._is_revoked = is_revoked
172
278
  # url -> (expires_monotonic, parsed JSON | None for negative entries)
173
279
  self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
174
280
 
175
281
  async def verify(
176
282
  self, method: str, url: str, headers: Mapping[str, str],
177
- *, allow_expired_auth_token: bool = False,
283
+ body: bytes | None = None,
178
284
  ) -> VerifiedSignature | None:
179
- """Verify the request's agent signature. Returns ``None`` when there is
180
- no ``Signature`` header, the signature is invalid, or the signer's keys
181
- cannot be (safely) fetched — never raises on untrusted input.
182
-
183
- ``allow_expired_auth_token`` (default False — leave it so for access
184
- decisions) lets a genuine ``aa-auth+jwt`` past its ``exp`` come back
185
- with ``expired=True`` instead of ``None``, for up to
186
- :data:`EXPIRED_AUTH_TOKEN_GRACE` seconds. Budgets need this: the
187
- resource owes the holder of an expired token a challenge carrying that
188
- token's final consumption record (draft §Budget Exhaustion), and it
189
- cannot build one for a token it refused to look at."""
285
+ """Verify the request's signature. ``None`` when there is no ``Signature``
286
+ header, the signature is invalid, or the signer's keys cannot be (safely)
287
+ fetched — never raises on untrusted input. Use :meth:`verify_detailed`
288
+ to learn why."""
289
+ result = await self.verify_detailed(method, url, headers, body)
290
+ return result if isinstance(result, VerifiedSignature) else None
291
+
292
+ async def verify_detailed(
293
+ self, method: str, url: str, headers: Mapping[str, str],
294
+ body: bytes | None = None,
295
+ ) -> VerifiedSignature | VerificationError | None:
296
+ """Like :meth:`verify`, but a failure comes back as a
297
+ :class:`VerificationError` (``None`` only when no ``Signature`` header is
298
+ present at all). ``body`` enables the -11 body-coverage rule: when
299
+ given and non-empty, ``content-digest`` and ``content-type`` must be
300
+ covered and the digest must match."""
190
301
  hdrs = {str(k): str(v) for k, v in headers.items()}
191
302
  if not any(k.lower() == "signature" for k in hdrs):
192
303
  return None
193
- result: VerifiedSignature | None = None
194
304
  try:
195
305
  if any(k.lower() == "signature-key" for k in hdrs):
196
- result = await self._verify_aauth(
197
- method, url, hdrs, allow_expired_auth_token=allow_expired_auth_token)
306
+ return await self._verify_signature_key(method, url, hdrs, body)
307
+ result = await self._verify_web_bot_auth(method, url, hdrs)
198
308
  if result is None:
199
- result = await self._verify_web_bot_auth(method, url, hdrs)
309
+ return VerificationError("invalid_signature",
310
+ "Web Bot Auth signature did not verify")
311
+ logger.info("httpsig verified scheme=%s agent=%s keyid=%s trusted=%s",
312
+ result.scheme, result.agent, result.keyid[:16], result.trusted)
313
+ return result
314
+ except _Refused as exc:
315
+ logger.info("httpsig refused: %s %s", exc.error.code, exc.error.detail[:160])
316
+ return exc.error
200
317
  except Exception as exc: # noqa: BLE001 — belt and braces
201
318
  logger.warning("httpsig verify error: %s", str(exc)[:200])
202
- return None
203
- if result is not None:
204
- logger.info(
205
- "httpsig verified scheme=%s agent=%s keyid=%s trusted=%s",
206
- result.scheme, result.agent, result.keyid[:16], result.trusted,
207
- )
208
- return result
319
+ return VerificationError("invalid_signature", "verifier error")
320
+
321
+ async def verify_server(
322
+ self, method: str, url: str, headers: Mapping[str, str],
323
+ body: bytes | None = None, *, allowed_dwk: tuple[str, ...] = SERVER_DWKS,
324
+ ) -> VerifiedSignature | VerificationError:
325
+ """Verify a request a *server* signed under the ``jwks_uri`` scheme
326
+ (§11.3.2): a PS or AS at a revocation or usage endpoint. The caller is
327
+ identified by ``id``; ``trusted`` says whether it is a configured PS."""
328
+ hdrs = {str(k): str(v) for k, v in headers.items()}
329
+ try:
330
+ members = parse_signature_key(_header(hdrs, "signature-key"))
331
+ if not members:
332
+ raise _refuse("invalid_signature", "Signature-Key header missing or malformed")
333
+ label, scheme, params = members[0]
334
+ if scheme != "jwks_uri":
335
+ raise _refuse("unsupported_scheme",
336
+ f"scheme {scheme!r}; servers sign under jwks_uri",
337
+ accept_schemes=("jwks_uri",))
338
+ dwk = str(params.get("dwk") or "")
339
+ if dwk not in allowed_dwk:
340
+ raise _refuse("invalid_key", f"dwk {dwk!r} is not accepted here")
341
+ return await self._verify_server_scheme(method, url, hdrs, body, label, params)
342
+ except _Refused as exc:
343
+ logger.info("httpsig server refused: %s %s", exc.error.code, exc.error.detail[:160])
344
+ return exc.error
345
+ except Exception as exc: # noqa: BLE001
346
+ logger.warning("httpsig server verify error: %s", str(exc)[:200])
347
+ return VerificationError("invalid_signature", "verifier error")
209
348
 
210
349
  async def aclose(self) -> None:
211
350
  if self._owns_client and self._http is not None:
@@ -226,6 +365,9 @@ class HttpsigVerifier:
226
365
  self._cache.pop(min(self._cache, key=lambda k: self._cache[k][0]), None)
227
366
  self._cache[url] = (time.monotonic() + ttl, doc)
228
367
 
368
+ def _cache_forget(self, url: str) -> None:
369
+ self._cache.pop(url, None)
370
+
229
371
  async def _fetch_json(self, url: str) -> dict[str, Any] | None:
230
372
  """Fetch an attacker-nameable identity document safely: https-only
231
373
  (except allow-listed dev hosts), public-IP-only, size-capped, cached."""
@@ -259,6 +401,42 @@ class HttpsigVerifier:
259
401
  self._cache_put(url, doc, cfg.cache_ttl)
260
402
  return doc
261
403
 
404
+ async def _jwks_key(self, jwks_url: str, kid: str | None) -> dict[str, Any]:
405
+ """The JWK with ``kid`` from the JWKS at ``jwks_url``; on an unknown kid the
406
+ document is refetched once (key rotation, §11.4). A JWKS of exactly one
407
+ key serves a header that names no kid."""
408
+ for attempt in (0, 1):
409
+ jwks = await self._fetch_json(jwks_url)
410
+ keys = list((jwks or {}).get("keys") or [])[:10]
411
+ if kid is None and len(keys) == 1 and isinstance(keys[0], dict):
412
+ return keys[0]
413
+ for k in keys:
414
+ if isinstance(k, dict) and k.get("kid") == kid:
415
+ return k
416
+ if attempt == 0:
417
+ self._cache_forget(jwks_url)
418
+ raise _refuse("unknown_key", f"kid {kid!r} is not in the issuer's JWKS")
419
+
420
+ async def _issuer_jwks_url(self, iss: str, dwk: str, *, override: str | None = None) -> str:
421
+ """Resolve ``{iss}/.well-known/{dwk}`` to its ``jwks_uri`` (§11.4), checking
422
+ the document's ``issuer`` against ``iss`` (issuer_missing / issuer_mismatch)."""
423
+ if override:
424
+ return override
425
+ metadata = await self._fetch_json(iss.rstrip("/") + "/.well-known/" + dwk)
426
+ if not metadata:
427
+ raise _refuse("issuer_missing",
428
+ f"metadata at {iss}/.well-known/{dwk} is unavailable")
429
+ issuer = metadata.get("issuer")
430
+ if not isinstance(issuer, str) or not issuer:
431
+ raise _refuse("issuer_missing", "metadata document has no issuer")
432
+ if issuer != iss:
433
+ raise _refuse("issuer_mismatch",
434
+ "metadata issuer differs from the identity it was fetched under")
435
+ jwks_url = metadata.get("jwks_uri")
436
+ if not isinstance(jwks_url, str) or not jwks_url:
437
+ raise _refuse("issuer_missing", "metadata document has no jwks_uri")
438
+ return jwks_url
439
+
262
440
  # ── Web Bot Auth ─────────────────────────────────────────────────────────
263
441
 
264
442
  async def _verify_web_bot_auth(
@@ -304,187 +482,263 @@ class HttpsigVerifier:
304
482
  label=str(res.label),
305
483
  )
306
484
 
307
- # ── AAuth (identity-based mode) ──────────────────────────────────────────
485
+ # ── AAuth: the Signature-Key header (§11.3) ──────────────────────────────
486
+
487
+ async def _verify_signature_key(
488
+ self, method: str, url: str, headers: dict[str, str], body: bytes | None,
489
+ ) -> VerifiedSignature:
490
+ members = parse_signature_key(_header(headers, "signature-key"))
491
+ if not members:
492
+ raise _refuse("invalid_signature", "Signature-Key header is malformed")
493
+ label, scheme, params = members[0]
494
+ if scheme == "jwt":
495
+ return await self._verify_jwt_scheme(method, url, headers, body, label, params)
496
+ if scheme == "jwks_uri":
497
+ return await self._verify_server_scheme(method, url, headers, body, label, params)
498
+ raise _refuse("unsupported_scheme", f"scheme {scheme!r} is not accepted",
499
+ accept_schemes=AAUTH_ACCEPT_SCHEMES)
500
+
501
+ def _check_signature_input(
502
+ self, headers: dict[str, str], label: str, body: bytes | None,
503
+ ) -> None:
504
+ """§11.3.3.1 covered components, §11.3.4 step 3 `created` window, `expires`."""
505
+ inputs = parse_signature_input(_header(headers, "signature-input"))
506
+ if label not in inputs:
507
+ raise _refuse("invalid_signature", f"Signature-Input has no member {label!r}")
508
+ covered, params = inputs[label]
509
+ required = list(self.config.required_components)
510
+ has_body = bool(body) or bool(_header(headers, "content-digest"))
511
+ if has_body:
512
+ required += ["content-digest", "content-type"]
513
+ missing = [c for c in required if c not in covered]
514
+ if missing:
515
+ raise _refuse("invalid_input", f"signature does not cover {', '.join(missing)}",
516
+ required_input=tuple(required))
517
+ now = int(time.time())
518
+ window = int(self.config.signature_window_seconds)
519
+ created = params.get("created")
520
+ if not isinstance(created, int):
521
+ raise _refuse("invalid_signature", "created parameter is required")
522
+ if created > now + window:
523
+ raise _refuse("clock_skew", "signature created ahead of the server's clock")
524
+ if created < now - window:
525
+ raise _refuse("invalid_signature", "signature created outside the validity window")
526
+ expires = params.get("expires")
527
+ if isinstance(expires, int) and expires < now:
528
+ raise _refuse("invalid_signature", "signature has expired")
529
+ if body:
530
+ digest = _header(headers, "content-digest")
531
+ expected = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
532
+ if digest.replace(" ", "") != expected:
533
+ raise _refuse("invalid_signature", "Content-Digest does not match the body")
534
+
535
+ async def _verify_pop(
536
+ self, method: str, url: str, headers: dict[str, str], label: str,
537
+ key: Any, algorithm: Any, detail: str,
538
+ ) -> None:
539
+ """Verify the HTTP Message Signature labelled ``label`` with ``key``."""
540
+ message = Message(method, url, headers)
541
+ verifier = _KeyidOptionalVerifier(
542
+ signature_algorithm=algorithm,
543
+ key_resolver=StaticKeyResolver({}, default=key),
544
+ component_resolver_class=DictKeyComponentResolver,
545
+ )
546
+ try:
547
+ # created/expires were judged above against the -11 window; the
548
+ # library's own max_age is set wide so it cannot pre-empt our codes.
549
+ results = await asyncio.to_thread(
550
+ verifier.verify, message, max_age=timedelta(days=365),
551
+ )
552
+ except Exception as exc: # noqa: BLE001
553
+ raise _refuse("invalid_signature", f"{detail}: {str(exc)[:120]}") from exc
554
+ if not any(str(r.label) == label for r in results):
555
+ raise _refuse("invalid_signature", f"no verified signature labelled {label!r}")
308
556
 
309
- async def _verify_aauth(
310
- self, method: str, url: str, headers: dict[str, str],
311
- *, allow_expired_auth_token: bool = False,
312
- ) -> VerifiedSignature | None:
557
+ async def _verify_jwt_scheme(
558
+ self, method: str, url: str, headers: dict[str, str], body: bytes | None,
559
+ label: str, params: dict[str, Any],
560
+ ) -> VerifiedSignature:
313
561
  try:
314
562
  import jwt as pyjwt # the [aauth] extra
315
- except ImportError:
316
- logger.info("Signature-Key present but pyjwt is not installed "
317
- "(pip install 'regent-httpsig[aauth]')")
318
- return None
319
-
320
- message = Message(method, url, headers)
321
- parsed = parse_signature_key_header(message.headers.get("signature-key", ""))
322
- if not parsed:
323
- return None
324
- label, token = parsed
325
-
563
+ except ImportError as exc:
564
+ raise _refuse("unsupported_scheme",
565
+ "pyjwt is not installed (pip install 'regent-httpsig[aauth]')",
566
+ accept_schemes=("jwks_uri",)) from exc
567
+ token = str(params.get("jwt") or "")
568
+ if not token:
569
+ raise _refuse("invalid_jwt", "jwt parameter is empty")
570
+ self._check_signature_input(headers, label, body)
326
571
  _register_fully_specified_algs()
327
572
  try:
328
573
  header = pyjwt.get_unverified_header(token)
329
574
  unverified = pyjwt.decode(token, options={"verify_signature": False})
330
- except Exception: # noqa: BLE001
331
- return None
332
- if header.get("alg") in (None, "none"):
333
- return None
575
+ except Exception as exc: # noqa: BLE001
576
+ raise _refuse("invalid_jwt", "token is malformed") from exc
334
577
 
335
- # -11 token-type dispatch: agent tokens (identity mode), person tokens
336
- # (PS-issued, per-resource, opt-in via config.resource_url) and auth
337
- # tokens (PS-issued budget carriers, opt-in via config.trusted_ps).
338
578
  typ = header.get("typ")
579
+ try:
580
+ return await self._verify_jwt_token(method, url, headers, label, token,
581
+ header, unverified, typ)
582
+ except _Refused as exc:
583
+ exc.error.token_typ = typ if isinstance(typ, str) else None
584
+ raise
585
+
586
+ async def _verify_jwt_token(
587
+ self, method: str, url: str, headers: dict[str, str], label: str,
588
+ token: str, header: dict[str, Any], unverified: dict[str, Any], typ: Any,
589
+ ) -> VerifiedSignature:
590
+ import jwt as pyjwt
591
+
592
+ # §11.5.2 step 1: typ first.
593
+ cfg = self.config
339
594
  jwks_override: str | None = None
595
+ expected_dwks: tuple[str, ...]
596
+ audience: str | None = None
340
597
  if typ == AAUTH_JWT_TYP:
341
- scheme, expected_dwk = "aauth", "aauth-agent.json"
342
- metadata_path, audience = AAUTH_METADATA_PATH, None
598
+ scheme, expected_dwks = "aauth", ("aauth-agent.json",)
343
599
  elif typ == AAUTH_PERSON_TYP:
344
- if not self.config.resource_url:
345
- logger.info("person token presented but config.resource_url is not "
346
- "set — person-token verification is disabled")
347
- return None
348
- scheme, expected_dwk = "aauth-person", "aauth-person.json"
349
- metadata_path, audience = AAUTH_PERSON_METADATA_PATH, self.config.resource_url
600
+ if not cfg.resource_url:
601
+ raise _refuse("invalid_key",
602
+ "person tokens are not accepted here (resource_url unset)")
603
+ scheme, expected_dwks = "aauth-person", ("aauth-person.json",)
604
+ audience = cfg.resource_url
350
605
  elif typ == AAUTH_AUTH_TYP:
351
- if not self.config.resource_url or not self.config.trusted_ps:
352
- logger.info("auth token presented but resource_url/trusted_ps is not "
353
- "configured — auth-token verification is disabled")
354
- return None
355
- scheme, expected_dwk = "aauth-auth", None
356
- metadata_path, audience = None, self.config.resource_url
606
+ if not cfg.resource_url or not cfg.trusted_ps:
607
+ raise _refuse("invalid_key",
608
+ "auth tokens are not accepted here (resource_url/trusted_ps unset)")
609
+ scheme, expected_dwks = "aauth-auth", ("aauth-person.json", "aauth-access.json")
610
+ audience = cfg.resource_url
357
611
  else:
358
- return None
359
-
360
- iss = str(unverified.get("iss", ""))
612
+ raise _refuse("invalid_jwt", f"typ {typ!r} is not an AAuth token type")
613
+
614
+ alg = header.get("alg")
615
+ allowed = list(AAUTH_ACCEPT_ALGS)
616
+ if not cfg.require_fully_specified_algs:
617
+ allowed.append("EdDSA")
618
+ if alg not in allowed:
619
+ raise _refuse("unsupported_algorithm", f"token alg {alg!r}",
620
+ accept_algs=AAUTH_ACCEPT_ALGS)
621
+
622
+ iss = str(unverified.get("iss") or "")
623
+ dwk = str(unverified.get("dwk") or "")
624
+ dev = bool(iss) and urlsplit(iss).hostname in cfg.insecure_hosts
625
+ if not iss or (not iss.startswith("https://") and not dev):
626
+ raise _refuse("invalid_jwt", "iss is not an https server identifier")
627
+ if dwk not in expected_dwks:
628
+ raise _refuse("invalid_jwt", f"dwk {dwk!r} is not {' or '.join(expected_dwks)}")
361
629
  if typ == AAUTH_AUTH_TYP:
362
- # The resource pins its PS: issuer must be explicitly trusted and its
363
- # JWKS location comes from configuration, not open-world discovery.
364
- override = self.config.trusted_ps.get(iss)
630
+ # The resource pins its PS/AS: the issuer must be configured.
631
+ override = cfg.trusted_ps.get(iss)
365
632
  if override is None:
366
- logger.info("auth token issuer %s is not a configured PS", iss[:100])
367
- return None
368
- jwks_override = override
369
- else:
370
- bad_iss = (unverified.get("dwk") != expected_dwk
371
- or not iss.startswith("https://"))
372
- if bad_iss and not (
373
- iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
374
- ):
375
- return None
376
-
377
- # AAuth -11 / RFC 9864: fully-specified algorithms. "EdDSA" (polymorphic)
378
- # is accepted only while require_fully_specified_algs is False — a
379
- # transition affordance for the -10 ecosystem.
380
- allowed_algs = ["Ed25519", "ES256", "RS256"]
381
- if not self.config.require_fully_specified_algs:
382
- allowed_algs.append("EdDSA")
383
-
384
- # 1) Verify the token against the issuer's published JWKS.
385
- if jwks_override is not None:
386
- jwks = await self._fetch_json(jwks_override)
387
- else:
388
- metadata = await self._fetch_json(iss.rstrip("/") + str(metadata_path))
389
- if not metadata or not metadata.get("jwks_uri"):
390
- return None
391
- jwks = await self._fetch_json(str(metadata["jwks_uri"]))
392
- if not jwks:
393
- return None
394
- issuer_key = None
395
- for k in list(jwks.get("keys") or [])[:10]:
396
- if k.get("kid") == header.get("kid") or len(jwks.get("keys") or []) == 1:
397
- try:
398
- issuer_key = pyjwt.PyJWK(k).key
399
- break
400
- except Exception: # noqa: BLE001
401
- # PyJWK's internal registry predates RFC 9864 names — a JWKS
402
- # advertising alg "Ed25519" is valid in -11 but unknown to it.
403
- try:
404
- issuer_key = load_ed25519_jwk(k)
405
- break
406
- except ValueError:
407
- continue
408
- if issuer_key is None:
409
- return None
410
- tolerate_exp = allow_expired_auth_token and typ == AAUTH_AUTH_TYP
633
+ raise _refuse("invalid_key", f"issuer {iss} is not a configured PS/AS")
634
+ jwks_override = override or None
635
+
636
+ # 1) The issuer's key, discovered through its metadata (or the pinned URL).
637
+ jwks_url = await self._issuer_jwks_url(iss, dwk, override=jwks_override)
638
+ issuer_jwk = await self._jwks_key(jwks_url, header.get("kid"))
639
+ key_alg = issuer_jwk.get("alg")
640
+ if cfg.require_fully_specified_algs and key_alg != alg:
641
+ raise _refuse("unsupported_algorithm", f"issuer key alg {key_alg!r} is not {alg}",
642
+ accept_algs=AAUTH_ACCEPT_ALGS)
643
+ try:
644
+ if key_alg in (None, "EdDSA") or alg == "Ed25519":
645
+ issuer_key: Any = load_ed25519_jwk(issuer_jwk)
646
+ else:
647
+ issuer_key = pyjwt.PyJWK({k: v for k, v in issuer_jwk.items() if k != "alg"}).key
648
+ except Exception as exc: # noqa: BLE001
649
+ raise _refuse("invalid_key", "issuer key cannot be parsed") from exc
650
+
651
+ # 2) The token itself: signature, exp with no tolerance, iat bound by the window.
411
652
  try:
412
653
  claims = pyjwt.decode(
413
- token,
414
- key=issuer_key,
415
- algorithms=allowed_algs,
416
- audience=audience,
417
- options={
418
- "require": ["iss", "sub", "exp", "iat"],
419
- "verify_aud": audience is not None,
420
- "verify_exp": not tolerate_exp,
421
- },
654
+ token, key=issuer_key, algorithms=[alg], audience=audience,
655
+ options={"require": ["iss", "sub", "exp", "iat"],
656
+ "verify_aud": audience is not None,
657
+ "verify_iat": False, "verify_exp": False},
422
658
  )
423
659
  except Exception as exc: # noqa: BLE001
424
- logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
425
- return None
426
- expired = False
427
- if tolerate_exp:
428
- overdue = int(time.time()) - int(claims.get("exp", 0))
429
- expired = overdue >= 0
430
- if overdue > EXPIRED_AUTH_TOKEN_GRACE:
431
- logger.info("aauth auth token expired %ss ago — past grace iss=%s",
432
- overdue, iss)
433
- return None
434
-
435
- # -11: person and auth tokens live at most one hour.
436
- if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
437
- lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
438
- if lifetime <= 0 or lifetime > PERSON_TOKEN_MAX_LIFETIME:
439
- logger.info("%s lifetime %ss out of bounds iss=%s", typ, lifetime, iss)
440
- return None
441
-
442
- # 2) Proof of possession: the request signature must verify against cnf.jwk.
660
+ raise _refuse("invalid_jwt", f"token does not verify: {str(exc)[:120]}") from exc
661
+ now = int(time.time())
662
+ window = int(cfg.signature_window_seconds)
663
+ try:
664
+ exp, iat = int(claims["exp"]), int(claims["iat"])
665
+ except (TypeError, ValueError) as exc:
666
+ raise _refuse("invalid_jwt", "exp/iat are not integers") from exc
667
+ if exp <= now:
668
+ raise _refuse("expired_jwt", "token exp is in the past")
669
+ if iat > now + window:
670
+ raise _refuse("clock_skew", "token iat is ahead of the server's clock")
671
+ lifetime = exp - iat
672
+ short_lived = typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP)
673
+ if short_lived and not 0 < lifetime <= PERSON_TOKEN_MAX_LIFETIME:
674
+ raise _refuse("invalid_jwt", f"{typ} lifetime {lifetime}s exceeds one hour")
675
+ jti = claims.get("jti")
676
+ if (self._is_revoked is not None and isinstance(jti, str) and jti
677
+ and await self._is_revoked(iss, jti)):
678
+ raise _refuse("revoked_jwt", "the issuer has withdrawn this token")
679
+
680
+ # 3) Proof of possession: the request signature must verify against cnf.jwk.
443
681
  cnf_jwk = (claims.get("cnf") or {}).get("jwk")
444
682
  if not isinstance(cnf_jwk, dict):
445
- return None
446
- # -11 strict mode: the cnf JWK "MUST carry a fully-specified alg member".
447
- if self.config.require_fully_specified_algs and cnf_jwk.get("alg") != "Ed25519":
448
- logger.info("cnf.jwk alg %r is not fully-specified iss=%s",
449
- cnf_jwk.get("alg"), iss)
450
- return None
451
- try:
452
- pop_key = load_ed25519_jwk(cnf_jwk)
453
- except ValueError:
454
- return None
455
- verifier = _KeyidOptionalVerifier(
456
- signature_algorithm=ED25519,
457
- key_resolver=StaticKeyResolver({}, default=pop_key),
458
- component_resolver_class=DictKeyComponentResolver,
459
- )
460
- try:
461
- # No expect_label: upstream requires expect_tag alongside it, and the
462
- # AAuth drafts' tag is still moving — we verify all signatures against
463
- # the possession key and match the Signature-Key label ourselves.
464
- results = await asyncio.to_thread(
465
- verifier.verify,
466
- message,
467
- max_age=timedelta(hours=self.config.max_age_hours),
468
- )
469
- except Exception as exc: # noqa: BLE001
470
- logger.info("aauth PoP invalid iss=%s: %s", iss, str(exc)[:200])
471
- return None
472
- if not any(str(r.label) == label for r in results):
473
- return None
683
+ raise _refuse("invalid_key", "token carries no cnf.jwk")
684
+ if not cfg.require_fully_specified_algs and "alg" not in cnf_jwk:
685
+ cnf_jwk = {**cnf_jwk, "alg": "Ed25519"}
686
+ pop_key, algorithm = _load_public_key(cnf_jwk)
687
+ await self._verify_pop(method, url, headers, label, pop_key, algorithm,
688
+ "proof of possession failed")
474
689
 
475
690
  return VerifiedSignature(
476
691
  scheme=scheme,
477
692
  agent=iss,
478
693
  keyid=jwk_thumbprint(cnf_jwk),
479
- # An auth-token issuer is by definition a configured, trusted PS.
480
- trusted=iss in self.config.trusted_agents or typ == AAUTH_AUTH_TYP,
694
+ # An auth-token issuer is by definition a configured, trusted PS/AS.
695
+ trusted=iss in cfg.trusted_agents or typ == AAUTH_AUTH_TYP,
481
696
  sub=str(claims.get("sub", "")),
482
697
  label=label,
483
698
  claims={
484
699
  k: claims[k]
485
- for k in ("iss", "sub", "exp", "ps", "aud", "jti", "mission_s256",
486
- "budget") # budgets: the envelope rides in the token
700
+ for k in ("iss", "sub", "exp", "iat", "ps", "aud", "jti", "dwk", "mission_s256",
701
+ "tenant", "budget") # budgets: the envelope rides in the token
487
702
  if k in claims
488
703
  },
489
- expired=expired,
490
704
  )
705
+
706
+ async def _verify_server_scheme(
707
+ self, method: str, url: str, headers: dict[str, str], body: bytes | None,
708
+ label: str, params: dict[str, Any],
709
+ ) -> VerifiedSignature:
710
+ """§11.3.2 / Signature-Key §3.6: ``sig=jwks_uri;id=…;dwk=…;kid=…``."""
711
+ server_id = str(params.get("id") or "")
712
+ dwk = str(params.get("dwk") or "")
713
+ kid = params.get("kid")
714
+ cfg = self.config
715
+ dev = bool(server_id) and urlsplit(server_id).hostname in cfg.insecure_hosts
716
+ if not server_id or (not server_id.startswith("https://") and not dev):
717
+ raise _refuse("invalid_key", "id is not an https server identifier")
718
+ if dwk not in SERVER_DWKS:
719
+ raise _refuse("invalid_key", f"dwk {dwk!r} is not an AAuth metadata document")
720
+ if not isinstance(kid, str) or not kid:
721
+ raise _refuse("invalid_key", "kid is required under the jwks_uri scheme")
722
+ self._check_signature_input(headers, label, body)
723
+ override = cfg.trusted_ps.get(server_id)
724
+ jwks_url = await self._issuer_jwks_url(server_id, dwk, override=override or None)
725
+ jwk = await self._jwks_key(jwks_url, kid)
726
+ key, algorithm = _load_public_key(jwk)
727
+ await self._verify_pop(method, url, headers, label, key, algorithm,
728
+ "server signature failed")
729
+ return VerifiedSignature(
730
+ scheme="aauth-server",
731
+ agent=server_id,
732
+ keyid=kid,
733
+ trusted=server_id in cfg.trusted_ps or server_id in cfg.trusted_agents,
734
+ label=label,
735
+ claims={"iss": server_id, "dwk": dwk, "kid": kid},
736
+ )
737
+
738
+
739
+ def _header(headers: Mapping[str, str], name: str) -> str:
740
+ lname = name.lower()
741
+ for k, v in headers.items():
742
+ if k.lower() == lname:
743
+ return v
744
+ return ""