regent-httpsig 0.6.0__py3-none-any.whl → 0.7.1__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/__init__.py +25 -3
- regent_httpsig/budget.py +53 -9
- regent_httpsig/config.py +31 -16
- regent_httpsig/fastapi.py +188 -132
- regent_httpsig/py.typed +0 -0
- regent_httpsig/sfv.py +58 -6
- regent_httpsig/sign.py +232 -24
- regent_httpsig/usage.py +105 -48
- regent_httpsig/verify.py +456 -202
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.1.dist-info}/METADATA +67 -27
- regent_httpsig-0.7.1.dist-info/RECORD +17 -0
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.1.dist-info}/WHEEL +1 -1
- regent_httpsig-0.6.0.dist-info/RECORD +0 -16
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.1.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.1.dist-info}/licenses/LICENSE +0 -0
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
``
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
60
|
+
parse_signature_input,
|
|
61
|
+
parse_signature_key,
|
|
49
62
|
)
|
|
50
63
|
|
|
51
|
-
__all__ = [
|
|
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
|
-
# -
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
|
|
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
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
283
|
+
body: bytes | None = None,
|
|
178
284
|
) -> VerifiedSignature | None:
|
|
179
|
-
"""Verify the request's
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
310
|
-
self, method: str, url: str, headers: dict[str, str],
|
|
311
|
-
|
|
312
|
-
) -> VerifiedSignature
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
#
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
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 ""
|