regent-httpsig 0.6.0__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/__init__.py +25 -3
- regent_httpsig/budget.py +53 -9
- regent_httpsig/config.py +31 -16
- regent_httpsig/fastapi.py +188 -132
- 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.0.dist-info}/METADATA +67 -27
- regent_httpsig-0.7.0.dist-info/RECORD +16 -0
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.0.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.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.6.0.dist-info → regent_httpsig-0.7.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -18,9 +18,19 @@ from regent_httpsig.netguard import NotPublicURL, assert_public_url
|
|
|
18
18
|
from regent_httpsig.sfv import (
|
|
19
19
|
build_aauth_budget_header,
|
|
20
20
|
build_aauth_requirement,
|
|
21
|
+
build_person_token_requirement,
|
|
21
22
|
parse_signature_agent,
|
|
23
|
+
parse_signature_input,
|
|
24
|
+
parse_signature_key,
|
|
25
|
+
)
|
|
26
|
+
from regent_httpsig.sign import (
|
|
27
|
+
DIRECTORY_MEDIA_TYPE,
|
|
28
|
+
EgressSigner,
|
|
29
|
+
ServerSigner,
|
|
30
|
+
content_digest,
|
|
31
|
+
generate_seed,
|
|
32
|
+
sign_request,
|
|
22
33
|
)
|
|
23
|
-
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
24
34
|
from regent_httpsig.usage import (
|
|
25
35
|
ResponseSigner,
|
|
26
36
|
UsageQueryError,
|
|
@@ -30,9 +40,14 @@ from regent_httpsig.usage import (
|
|
|
30
40
|
parse_usage_request,
|
|
31
41
|
validate_budget_grant,
|
|
32
42
|
)
|
|
33
|
-
from regent_httpsig.verify import
|
|
43
|
+
from regent_httpsig.verify import (
|
|
44
|
+
WBA_TAG,
|
|
45
|
+
HttpsigVerifier,
|
|
46
|
+
VerificationError,
|
|
47
|
+
VerifiedSignature,
|
|
48
|
+
)
|
|
34
49
|
|
|
35
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.7.0"
|
|
36
51
|
|
|
37
52
|
__all__ = [
|
|
38
53
|
"DIRECTORY_MEDIA_TYPE",
|
|
@@ -46,7 +61,14 @@ __all__ = [
|
|
|
46
61
|
"InvalidBudgetClaim",
|
|
47
62
|
"NotPublicURL",
|
|
48
63
|
"UnitMismatch",
|
|
64
|
+
"VerificationError",
|
|
49
65
|
"VerifiedSignature",
|
|
66
|
+
"ServerSigner",
|
|
67
|
+
"content_digest",
|
|
68
|
+
"sign_request",
|
|
69
|
+
"build_person_token_requirement",
|
|
70
|
+
"parse_signature_input",
|
|
71
|
+
"parse_signature_key",
|
|
50
72
|
"WBA_TAG",
|
|
51
73
|
"__version__",
|
|
52
74
|
"assert_public_url",
|
regent_httpsig/budget.py
CHANGED
|
@@ -192,6 +192,10 @@ class InMemoryMeter:
|
|
|
192
192
|
usage_key_retention: float = 86400.0) -> None:
|
|
193
193
|
self._pools: dict[MeterKey, _Pool] = {}
|
|
194
194
|
self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
|
|
195
|
+
# -11 §11.12.1: revocations keyed by (iss, jti), kept until the token's
|
|
196
|
+
# own exp plus clock skew — whether or not the token was ever seen here.
|
|
197
|
+
self._revocations: dict[tuple[str, str], float] = {} # (iss, jti) -> exp (wall)
|
|
198
|
+
self._revocation_skew = 60.0
|
|
195
199
|
self._lock = asyncio.Lock()
|
|
196
200
|
self._rids = itertools.count(1)
|
|
197
201
|
self._reservation_ttl = reservation_ttl
|
|
@@ -326,12 +330,51 @@ class InMemoryMeter:
|
|
|
326
330
|
pool = self._purge(key, time.monotonic())
|
|
327
331
|
return 0 if pool is None else self._remaining(pool, jti)
|
|
328
332
|
|
|
333
|
+
def _prune_revocations(self, wall: float) -> None:
|
|
334
|
+
for pair, exp in list(self._revocations.items()):
|
|
335
|
+
if wall > exp + self._revocation_skew:
|
|
336
|
+
del self._revocations[pair]
|
|
337
|
+
|
|
338
|
+
def _withdraw(self, iss: str, jti: str) -> bool:
|
|
339
|
+
"""Withdraw a known token's grant (call under lock). ``False`` when the
|
|
340
|
+
pair was never granted here — the revocation is still on record."""
|
|
341
|
+
key = self._jti_index.get((iss, jti))
|
|
342
|
+
if key is None:
|
|
343
|
+
return False
|
|
344
|
+
pool = self._purge(key, time.monotonic())
|
|
345
|
+
if pool is None:
|
|
346
|
+
return False
|
|
347
|
+
pool.revoked.setdefault(jti, time.time())
|
|
348
|
+
pool.grants.pop(jti, None)
|
|
349
|
+
pool.last_activity = time.monotonic()
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
async def record_revocation(self, iss: str, jti: str, exp: float) -> bool:
|
|
353
|
+
"""Record that ``iss`` revoked ``jti`` (-11 §11.12.1): remembered until
|
|
354
|
+
``exp`` plus clock skew whether or not the token was ever presented
|
|
355
|
+
here, so a later presentation is answered ``revoked_jwt``. If the token
|
|
356
|
+
holds a grant, it is withdrawn — no new request can reserve against it;
|
|
357
|
+
requests already in flight complete and are committed as usual.
|
|
358
|
+
Returns whether the token was known here. Idempotent."""
|
|
359
|
+
async with self._lock:
|
|
360
|
+
wall = time.time()
|
|
361
|
+
self._prune_revocations(wall)
|
|
362
|
+
if wall <= float(exp) + self._revocation_skew: # §11.12.1: past exp + skew is droppable
|
|
363
|
+
self._revocations[(iss, jti)] = float(exp)
|
|
364
|
+
return self._withdraw(iss, jti)
|
|
365
|
+
|
|
366
|
+
async def is_revoked(self, iss: str, jti: str) -> bool:
|
|
367
|
+
"""Whether ``(iss, jti)`` is on record as revoked — the hook for
|
|
368
|
+
:class:`HttpsigVerifier` (``is_revoked=meter.is_revoked``)."""
|
|
369
|
+
async with self._lock:
|
|
370
|
+
self._prune_revocations(time.time())
|
|
371
|
+
return (iss, jti) in self._revocations
|
|
372
|
+
|
|
329
373
|
async def revoke(self, iss: str, jti: str) -> bool:
|
|
330
|
-
"""Revoke
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
endpoint answers 404). Idempotent."""
|
|
374
|
+
"""Revoke a token this meter holds a grant for, by ``(iss, jti)``. Like
|
|
375
|
+
:meth:`record_revocation` with the grant's own ``exp``; ``False`` when
|
|
376
|
+
the pair is unknown here (nothing is recorded then — use
|
|
377
|
+
:meth:`record_revocation` when the issuer told you the exp)."""
|
|
335
378
|
async with self._lock:
|
|
336
379
|
key = self._jti_index.get((iss, jti))
|
|
337
380
|
if key is None:
|
|
@@ -339,10 +382,11 @@ class InMemoryMeter:
|
|
|
339
382
|
pool = self._purge(key, time.monotonic())
|
|
340
383
|
if pool is None:
|
|
341
384
|
return False
|
|
342
|
-
pool.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
385
|
+
grant = pool.grants.get(jti)
|
|
386
|
+
wall = time.time()
|
|
387
|
+
exp = wall + max(0.0, grant[1] - time.monotonic()) if grant else wall
|
|
388
|
+
self._revocations[(iss, jti)] = max(self._revocations.get((iss, jti), 0.0), exp)
|
|
389
|
+
return self._withdraw(iss, jti)
|
|
346
390
|
|
|
347
391
|
async def revocation_state(self, key: MeterKey, jti: str) -> TokenRevoked | None:
|
|
348
392
|
"""``TokenRevoked`` (with its drain state) if the token was revoked
|
regent_httpsig/config.py
CHANGED
|
@@ -5,22 +5,35 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
|
|
8
|
-
__all__ = ["HttpsigConfig"]
|
|
8
|
+
__all__ = ["AAUTH_REQUIRED_COMPONENTS", "HttpsigConfig"]
|
|
9
|
+
|
|
10
|
+
# AAuth -11 §11.3.3.1: what every agent signature MUST cover. A request with a
|
|
11
|
+
# body to a PS/AS/revocation endpoint additionally covers content-digest and
|
|
12
|
+
# content-type; the verifier adds those when it is handed the body.
|
|
13
|
+
AAUTH_REQUIRED_COMPONENTS: tuple[str, ...] = ("@method", "@authority", "@path", "signature-key")
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
@dataclass(frozen=True)
|
|
12
17
|
class HttpsigConfig:
|
|
13
18
|
"""Configuration for :class:`regent_httpsig.HttpsigVerifier`.
|
|
14
19
|
|
|
15
|
-
Defaults are production-safe
|
|
16
|
-
|
|
17
|
-
``
|
|
20
|
+
Defaults are production-safe and, on the AAuth path, follow -11 as
|
|
21
|
+
published: a 60-second ``created`` window, fully-specified algorithms only,
|
|
22
|
+
no clock-skew tolerance on ``exp``. Web Bot Auth keeps its own, looser age
|
|
23
|
+
rule (``max_age_hours``) because that profile defines no window."""
|
|
18
24
|
|
|
19
25
|
# Origins/issuers you additionally mark as trusted (``VerifiedSignature.trusted``).
|
|
20
26
|
# Verification itself never depends on this — it only annotates the result.
|
|
21
27
|
trusted_agents: frozenset[str] = field(default_factory=frozenset)
|
|
22
|
-
#
|
|
28
|
+
# Web Bot Auth only: reject signatures created earlier than this many hours ago.
|
|
23
29
|
max_age_hours: int = 25
|
|
30
|
+
# AAuth -11 §11.3.4 step 3: the signature validity window for `created`, in
|
|
31
|
+
# seconds. Older than the window → invalid_signature; further ahead of our
|
|
32
|
+
# clock → clock_skew. The same window bounds a token's `iat` (§11.5.2).
|
|
33
|
+
# Advertise a different value in resource metadata as `signature_window`.
|
|
34
|
+
signature_window_seconds: int = 60
|
|
35
|
+
# AAuth -11 §11.3.3.1: covered components every agent signature must include.
|
|
36
|
+
required_components: tuple[str, ...] = AAUTH_REQUIRED_COMPONENTS
|
|
24
37
|
# Key-directory cache TTL (seconds); failures are cached for negative_cache_ttl.
|
|
25
38
|
cache_ttl: float = 600.0
|
|
26
39
|
negative_cache_ttl: float = 120.0
|
|
@@ -31,17 +44,19 @@ class HttpsigConfig:
|
|
|
31
44
|
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
|
|
32
45
|
# e.g. frozenset({"localhost"})). Leave empty in production.
|
|
33
46
|
insecure_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
34
|
-
# AAuth -11
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
require_fully_specified_algs: bool =
|
|
47
|
+
# AAuth -11 §11.3.1: JOSE algs must be fully-specified (RFC 9864); the
|
|
48
|
+
# polymorphic "EdDSA" and keys without `alg` are refused. False is the
|
|
49
|
+
# pre-cut-over behaviour kept only for private test rigs; -11 forbids it.
|
|
50
|
+
require_fully_specified_algs: bool = True
|
|
38
51
|
# This service's public URL (e.g. "https://api.example"). Required to accept
|
|
39
|
-
# AAuth person tokens — their `aud` must name this resource.
|
|
40
|
-
#
|
|
52
|
+
# AAuth person tokens and auth tokens — their `aud` must name this resource.
|
|
53
|
+
# None disables both paths.
|
|
41
54
|
resource_url: str | None = None
|
|
42
|
-
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes)
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
55
|
+
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes) and
|
|
56
|
+
# signed server requests (the jwks_uri scheme, §11.3.2): issuer → JWKS URL
|
|
57
|
+
# for each Person Server / Access Server this resource accepts. A resource has
|
|
58
|
+
# an established relationship with its PS, so trust is pinned by
|
|
59
|
+
# configuration. The value may be "" to discover the JWKS through the
|
|
60
|
+
# issuer's metadata document ({id}/.well-known/{dwk}) instead of a fixed URL.
|
|
61
|
+
# Empty (the default) disables the auth-token and server paths entirely.
|
|
47
62
|
trusted_ps: Mapping[str, str] = field(default_factory=dict)
|
regent_httpsig/fastapi.py
CHANGED
|
@@ -13,9 +13,12 @@ Usage::
|
|
|
13
13
|
if sig:
|
|
14
14
|
... # sig.agent == "https://chatgpt.com", sig.keyid, sig.trusted
|
|
15
15
|
|
|
16
|
-
``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
|
|
17
|
-
``
|
|
18
|
-
|
|
16
|
+
``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
|
|
17
|
+
(``SignatureErrorDep`` tells you why). ``RequiredSignatureDep`` is
|
|
18
|
+
authentication: a signed request that fails is answered ``401`` with the
|
|
19
|
+
``Signature-Error`` header of draft-hardt-httpbis-signature-key (the code
|
|
20
|
+
AAuth -11 §11.3.4 assigns), an unsigned one with ``AAuth-Requirement:
|
|
21
|
+
requirement=agent-token`` (-11 §6.1).
|
|
19
22
|
|
|
20
23
|
Proxy note: the signer signed the PUBLIC url. Behind a reverse proxy this
|
|
21
24
|
dependency rebuilds it from ``X-Forwarded-Proto`` + ``Host`` — make sure your
|
|
@@ -44,15 +47,31 @@ from regent_httpsig.budget import (
|
|
|
44
47
|
TokenRevoked,
|
|
45
48
|
UnitMismatch,
|
|
46
49
|
)
|
|
47
|
-
from regent_httpsig.sfv import
|
|
48
|
-
|
|
50
|
+
from regent_httpsig.sfv import (
|
|
51
|
+
build_aauth_budget_header,
|
|
52
|
+
build_aauth_requirement,
|
|
53
|
+
build_person_token_requirement,
|
|
54
|
+
)
|
|
55
|
+
from regent_httpsig.verify import (
|
|
56
|
+
AAUTH_AUTH_TYP,
|
|
57
|
+
AAUTH_PERSON_TYP,
|
|
58
|
+
HttpsigVerifier,
|
|
59
|
+
VerificationError,
|
|
60
|
+
VerifiedSignature,
|
|
61
|
+
)
|
|
49
62
|
|
|
50
63
|
__all__ = [
|
|
51
64
|
"BudgetMiddleware",
|
|
52
65
|
"RequiredSignatureDep",
|
|
53
66
|
"SignatureDep",
|
|
67
|
+
"SignatureErrorDep",
|
|
68
|
+
"VerificationError",
|
|
54
69
|
"VerifiedSignature",
|
|
55
70
|
"attach",
|
|
71
|
+
"get_signature",
|
|
72
|
+
"get_signature_error",
|
|
73
|
+
"require_server_signature",
|
|
74
|
+
"signature_error_response",
|
|
56
75
|
]
|
|
57
76
|
|
|
58
77
|
_STATE_ATTR = "regent_httpsig_verifier"
|
|
@@ -74,49 +93,128 @@ def _public_url(request: Request) -> str:
|
|
|
74
93
|
return f"{scheme}://{host}{path}{query}"
|
|
75
94
|
|
|
76
95
|
|
|
77
|
-
|
|
78
|
-
"""Optional verification: zero-cost without a Signature header, never raises."""
|
|
96
|
+
def _verifier_of(request: Request) -> HttpsigVerifier:
|
|
79
97
|
verifier: HttpsigVerifier | None = getattr(request.app.state, _STATE_ATTR, None)
|
|
80
98
|
if verifier is None:
|
|
81
99
|
raise RuntimeError(
|
|
82
100
|
"regent-httpsig verifier not attached — call "
|
|
83
101
|
"regent_httpsig.fastapi.attach(app, HttpsigVerifier()) at startup"
|
|
84
102
|
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
103
|
+
return verifier
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _run_verifier(
|
|
107
|
+
verifier: HttpsigVerifier, request: Request,
|
|
108
|
+
) -> VerifiedSignature | VerificationError | None:
|
|
109
|
+
"""Verify once per request; both outcomes are cached on ``request.state``."""
|
|
110
|
+
cached: Any = getattr(request.state, "regent_httpsig_outcome", "unset")
|
|
88
111
|
if cached != "unset":
|
|
89
|
-
return cached
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
return cached if isinstance(cached, VerifiedSignature | VerificationError) else None
|
|
113
|
+
outcome: VerifiedSignature | VerificationError | None = None
|
|
114
|
+
if "signature" in request.headers:
|
|
115
|
+
# The -11 body rule (content-digest covered and matching) applies to the
|
|
116
|
+
# Signature-Key path; Web Bot Auth has no such rule and needs no body.
|
|
117
|
+
body = await request.body() if "signature-key" in request.headers else None
|
|
118
|
+
outcome = await verifier.verify_detailed(
|
|
119
|
+
request.method, _public_url(request), dict(request.headers), body,
|
|
120
|
+
)
|
|
121
|
+
request.state.regent_httpsig_outcome = outcome
|
|
122
|
+
request.state.regent_httpsig_result = (
|
|
123
|
+
outcome if isinstance(outcome, VerifiedSignature) else None)
|
|
124
|
+
request.state.regent_httpsig_error = (
|
|
125
|
+
outcome if isinstance(outcome, VerificationError) else None)
|
|
126
|
+
return outcome
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def signature_error_response(
|
|
130
|
+
error: VerificationError, *, code: str | None = None, requirement: str | None = None,
|
|
131
|
+
) -> JSONResponse:
|
|
132
|
+
"""``401`` + ``Signature-Error`` (+ ``Accept-Signature-*``) with the
|
|
133
|
+
problem+json body of draft-hardt-httpbis-signature-key §5. ``requirement``
|
|
134
|
+
adds an ``AAuth-Requirement`` header; ``code`` adds a legacy ``code`` member."""
|
|
135
|
+
headers = error.headers()
|
|
136
|
+
if requirement:
|
|
137
|
+
headers["AAuth-Requirement"] = requirement
|
|
138
|
+
body = error.problem(401)
|
|
139
|
+
if code:
|
|
140
|
+
body["code"] = code
|
|
141
|
+
return JSONResponse(status_code=401, content=body, headers=headers,
|
|
142
|
+
media_type="application/problem+json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _repair_requirement(error: VerificationError) -> str | None:
|
|
146
|
+
"""-11 §11.12.5 / budgets "auth token expired": a revoked or expired person
|
|
147
|
+
or auth token is repaired by a fresh person token; nothing repairs an agent
|
|
148
|
+
token (the agent obtains a new one from its provider)."""
|
|
149
|
+
if error.code in ("revoked_jwt", "expired_jwt") and error.token_typ in (
|
|
150
|
+
AAUTH_PERSON_TYP, AAUTH_AUTH_TYP,
|
|
151
|
+
):
|
|
152
|
+
return build_person_token_requirement()
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def get_signature(request: Request) -> VerifiedSignature | None:
|
|
157
|
+
"""Optional verification: zero-cost without a Signature header, never raises."""
|
|
158
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
159
|
+
return outcome if isinstance(outcome, VerifiedSignature) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def get_signature_error(request: Request) -> VerificationError | None:
|
|
163
|
+
"""Why a signed request did not verify (``None`` when unsigned or verified)."""
|
|
164
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
165
|
+
return outcome if isinstance(outcome, VerificationError) else None
|
|
95
166
|
|
|
96
167
|
|
|
97
168
|
async def require_signature(request: Request) -> VerifiedSignature:
|
|
98
169
|
"""Hard requirement: a fully verified signature, or a 401 that tells the
|
|
99
|
-
agent exactly
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
170
|
+
agent exactly what went wrong (``Signature-Error``) or what to present
|
|
171
|
+
(``AAuth-Requirement: requirement=agent-token``)."""
|
|
172
|
+
outcome = await _run_verifier(_verifier_of(request), request)
|
|
173
|
+
if isinstance(outcome, VerifiedSignature):
|
|
174
|
+
return outcome
|
|
175
|
+
if isinstance(outcome, VerificationError):
|
|
176
|
+
headers = outcome.headers()
|
|
177
|
+
requirement = _repair_requirement(outcome)
|
|
178
|
+
if requirement:
|
|
179
|
+
headers["AAuth-Requirement"] = requirement
|
|
180
|
+
raise HTTPException(status_code=401, headers=headers,
|
|
181
|
+
detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=401,
|
|
184
|
+
headers={"AAuth-Requirement": "requirement=agent-token"},
|
|
185
|
+
detail={
|
|
186
|
+
"code": "SIGNATURE_REQUIRED",
|
|
187
|
+
"message": (
|
|
188
|
+
"Sign this request with RFC 9421 HTTP Message Signatures: either "
|
|
189
|
+
"AAuth (your agent token in the Signature-Key header under the jwt "
|
|
190
|
+
"scheme, signature over @method @authority @path signature-key bound "
|
|
191
|
+
'to its cnf.jwk) or Web Bot Auth (tag="web-bot-auth", Ed25519 key '
|
|
192
|
+
"published at {your-origin}/.well-known/http-message-signatures-directory, "
|
|
193
|
+
"Signature-Agent header naming that origin)."
|
|
194
|
+
),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def require_server_signature(request: Request) -> VerifiedSignature:
|
|
200
|
+
"""For endpoints servers call (usage, revocation): the caller must sign
|
|
201
|
+
under the ``jwks_uri`` scheme (-11 §11.3.2); the body is covered. A
|
|
202
|
+
failure is ``401`` + ``Signature-Error``."""
|
|
203
|
+
verifier = _verifier_of(request)
|
|
204
|
+
body = await request.body()
|
|
205
|
+
outcome = await verifier.verify_server(
|
|
206
|
+
request.method, _public_url(request), dict(request.headers), body,
|
|
207
|
+
)
|
|
208
|
+
if isinstance(outcome, VerifiedSignature):
|
|
209
|
+
return outcome
|
|
210
|
+
raise HTTPException(status_code=401, headers=outcome.headers(),
|
|
211
|
+
detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
|
|
116
212
|
|
|
117
213
|
|
|
118
214
|
SignatureDep = Depends(get_signature)
|
|
215
|
+
SignatureErrorDep = Depends(get_signature_error)
|
|
119
216
|
RequiredSignatureDep = Depends(require_signature)
|
|
217
|
+
RequiredServerSignatureDep = Depends(require_server_signature)
|
|
120
218
|
|
|
121
219
|
|
|
122
220
|
# ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
|
|
@@ -133,20 +231,17 @@ async def _maybe_await(value: Any) -> Any:
|
|
|
133
231
|
|
|
134
232
|
class BudgetMiddleware(BaseHTTPMiddleware):
|
|
135
233
|
"""Meter budget-carrying requests: reserve the maximum cost atomically,
|
|
136
|
-
serve, commit the actual
|
|
137
|
-
``AAuth-Budget`` header. The full checklist a resource owes the draft —
|
|
138
|
-
pricing excepted, which is the one thing only the resource can know.
|
|
234
|
+
serve, commit the actual cost, answer with ``AAuth-Budget``.
|
|
139
235
|
|
|
140
|
-
|
|
236
|
+
::
|
|
141
237
|
|
|
142
|
-
meter = InMemoryMeter()
|
|
143
238
|
app.add_middleware(
|
|
144
239
|
BudgetMiddleware,
|
|
145
240
|
verifier=HttpsigVerifier(HttpsigConfig(
|
|
146
241
|
resource_url="https://api.example",
|
|
147
242
|
trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
|
|
148
243
|
)),
|
|
149
|
-
meter=
|
|
244
|
+
meter=InMemoryMeter(),
|
|
150
245
|
price_fn=lambda request: PRICES.get(request.url.path),
|
|
151
246
|
)
|
|
152
247
|
|
|
@@ -157,6 +252,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
157
252
|
- ``require=False`` (default) lets requests without a budget envelope pass
|
|
158
253
|
through untouched — run per-decision authorization for them instead.
|
|
159
254
|
``require=True`` refuses them with 401 + ``AAuth-Requirement``.
|
|
255
|
+
- A *signed* request that does not verify is refused with ``401`` +
|
|
256
|
+
``Signature-Error`` (-11 §11.3.4) whatever ``require`` says. A revoked or
|
|
257
|
+
expired auth token is ``revoked_jwt`` / ``expired_jwt`` plus
|
|
258
|
+
``AAuth-Requirement: requirement=person-token`` (-11 §11.12.5) — no
|
|
259
|
+
resource token, no ``AAuth-Budget``; its final consumption record reaches
|
|
260
|
+
the issuer through the usage endpoint.
|
|
160
261
|
- Error responses (4xx/5xx) release the reservation — nothing was served,
|
|
161
262
|
the envelope is not charged.
|
|
162
263
|
- ``resource_token_provider(key, record)`` (optional) mints the resource
|
|
@@ -165,6 +266,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
165
266
|
the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
|
|
166
267
|
Record — one record, two members) or ``None`` when nothing was metered
|
|
167
268
|
against it yet.
|
|
269
|
+
- Wire the verifier to the meter's revocation record
|
|
270
|
+
(``HttpsigVerifier(..., is_revoked=meter.is_revoked)``) so a revoked
|
|
271
|
+
token is named as revoked before it is metered; the middleware checks the
|
|
272
|
+
meter too, for verifiers wired otherwise.
|
|
168
273
|
"""
|
|
169
274
|
|
|
170
275
|
def __init__(
|
|
@@ -191,14 +296,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
191
296
|
if max_cost is None:
|
|
192
297
|
return await call_next(request)
|
|
193
298
|
|
|
194
|
-
|
|
195
|
-
if
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
# token's FINAL consumption record (stated at/after its exp), which
|
|
199
|
-
# is what lets its issuer settle the allocation exactly. Without
|
|
200
|
-
# this challenge an issuer never sees a final figure at all.
|
|
201
|
-
return await self._expired_challenge(sig)
|
|
299
|
+
outcome = await _run_verifier(self._verifier, request)
|
|
300
|
+
if isinstance(outcome, VerificationError):
|
|
301
|
+
return self._signature_error(outcome)
|
|
302
|
+
sig = outcome
|
|
202
303
|
envelope: BudgetClaim | None = None
|
|
203
304
|
if sig is not None:
|
|
204
305
|
try:
|
|
@@ -217,6 +318,9 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
217
318
|
str(sig.claims.get("sub", "")),
|
|
218
319
|
str(sig.claims.get("aud", "")),
|
|
219
320
|
)
|
|
321
|
+
is_revoked = getattr(self._meter, "is_revoked", None)
|
|
322
|
+
if is_revoked is not None and await is_revoked(key[0], jti):
|
|
323
|
+
return self._revoked()
|
|
220
324
|
try:
|
|
221
325
|
# sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
|
|
222
326
|
# recorded so refusal-time consumption records are scoped to the
|
|
@@ -229,23 +333,23 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
229
333
|
return self._refusal(reason="insufficient-budget", envelope=envelope,
|
|
230
334
|
remaining=0, key=key)
|
|
231
335
|
|
|
232
|
-
|
|
233
|
-
if isinstance(
|
|
234
|
-
# §Token Scope:
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
return
|
|
238
|
-
if isinstance(
|
|
239
|
-
reason = "budget-exhausted" if
|
|
336
|
+
outcome_r = await self._meter.reserve(key, jti, int(max_cost))
|
|
337
|
+
if isinstance(outcome_r, TokenRevoked):
|
|
338
|
+
# §Token Scope / -11 §11.12.5: say it was revoked, ask for a person
|
|
339
|
+
# token, carry no resource token. The final record (once nothing is
|
|
340
|
+
# in flight, AAuth #151) is the usage endpoint's to report.
|
|
341
|
+
return self._revoked()
|
|
342
|
+
if isinstance(outcome_r, InsufficientBudget):
|
|
343
|
+
reason = "budget-exhausted" if outcome_r.exhausted else "insufficient-budget"
|
|
240
344
|
return await self._refusal_with_token(
|
|
241
|
-
reason=reason, envelope=envelope, remaining=
|
|
345
|
+
reason=reason, envelope=envelope, remaining=outcome_r.remaining,
|
|
242
346
|
key=key, jti=jti,
|
|
243
347
|
# `required` rides only on insufficient-budget: what THIS
|
|
244
348
|
# request needed, so the agent can lower its bound and retry.
|
|
245
349
|
required=int(max_cost) if reason == "insufficient-budget" else None,
|
|
246
350
|
)
|
|
247
351
|
|
|
248
|
-
reservation: Reservation =
|
|
352
|
+
reservation: Reservation = outcome_r
|
|
249
353
|
try:
|
|
250
354
|
response = await call_next(request)
|
|
251
355
|
except Exception:
|
|
@@ -292,90 +396,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
292
396
|
|
|
293
397
|
def _commit_after_stream(self, request: Request, response: Response,
|
|
294
398
|
reservation: Reservation, max_cost: int) -> None:
|
|
295
|
-
"""Wrap the body iterator
|
|
296
|
-
set
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
399
|
+
"""Wrap the body iterator so the reservation is committed (at the
|
|
400
|
+
handler's actual cost if it set one mid-stream, else the full hold)
|
|
401
|
+
when the stream ends, and released if it breaks before a byte is sent."""
|
|
402
|
+
meter = self._meter
|
|
403
|
+
original = response.body_iterator # type: ignore[attr-defined]
|
|
300
404
|
|
|
301
405
|
async def metered() -> Any:
|
|
302
|
-
|
|
406
|
+
sent = False
|
|
303
407
|
try:
|
|
304
|
-
async for chunk in
|
|
408
|
+
async for chunk in original:
|
|
409
|
+
sent = True
|
|
305
410
|
yield chunk
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
411
|
+
except BaseException:
|
|
412
|
+
await (meter.commit(reservation, max_cost) if sent
|
|
413
|
+
else meter.release(reservation))
|
|
414
|
+
raise
|
|
415
|
+
actual = getattr(request.state, "budget_cost", None)
|
|
416
|
+
await meter.commit(reservation,
|
|
417
|
+
int(actual) if actual is not None else max_cost)
|
|
311
418
|
|
|
312
419
|
response.body_iterator = metered() # type: ignore[attr-defined]
|
|
313
420
|
|
|
314
421
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
315
422
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
# per-request cache still must not carry it — a later dependency
|
|
329
|
-
# reading the cache would otherwise see a token that is not valid.
|
|
330
|
-
request.state.regent_httpsig_result = (
|
|
331
|
-
None if result is not None and result.expired else result)
|
|
332
|
-
return result
|
|
333
|
-
|
|
334
|
-
async def _revoked_challenge(self, key: MeterKey, jti: str, drained: bool) -> Response:
|
|
335
|
-
token: str | None = None
|
|
336
|
-
if self._resource_token is not None:
|
|
337
|
-
try:
|
|
338
|
-
record = await self._meter.consumed_record(key, jti) if drained else None
|
|
339
|
-
token = await _maybe_await(self._resource_token(key, record))
|
|
340
|
-
except Exception: # noqa: BLE001 — the challenge must not fail on the extras
|
|
341
|
-
logger.warning("resource_token_provider failed", exc_info=True)
|
|
342
|
-
return JSONResponse(
|
|
343
|
-
status_code=401,
|
|
344
|
-
content={
|
|
345
|
-
"code": "AUTH_TOKEN_REVOKED",
|
|
346
|
-
"message": "This auth token was revoked by its issuer. Take the resource "
|
|
347
|
-
"token in AAuth-Requirement to your PS for a fresh one."
|
|
348
|
-
+ ("" if drained else " Its final consumption record follows once "
|
|
349
|
-
"in-flight requests settle."),
|
|
350
|
-
},
|
|
351
|
-
headers={"AAuth-Requirement": build_aauth_requirement(
|
|
352
|
-
reason=None, resource_token=token)},
|
|
353
|
-
)
|
|
354
|
-
|
|
355
|
-
async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
|
|
356
|
-
jti = str(sig.claims.get("jti") or "")
|
|
357
|
-
key: MeterKey = (
|
|
358
|
-
str(sig.claims.get("iss", "")),
|
|
359
|
-
str(sig.claims.get("sub", "")),
|
|
360
|
-
str(sig.claims.get("aud", "")),
|
|
361
|
-
)
|
|
362
|
-
token: str | None = None
|
|
363
|
-
if self._resource_token is not None and jti:
|
|
364
|
-
try:
|
|
365
|
-
record = await self._meter.consumed_record(key, jti)
|
|
366
|
-
token = await _maybe_await(self._resource_token(key, record))
|
|
367
|
-
except Exception: # noqa: BLE001 — the challenge must not fail on the extras
|
|
368
|
-
logger.warning("resource_token_provider failed", exc_info=True)
|
|
369
|
-
return JSONResponse(
|
|
370
|
-
status_code=401,
|
|
371
|
-
content={
|
|
372
|
-
"code": "AUTH_TOKEN_EXPIRED",
|
|
373
|
-
"message": "The auth token has expired. Take the resource token in "
|
|
374
|
-
"AAuth-Requirement to your PS for a fresh one.",
|
|
375
|
-
},
|
|
376
|
-
headers={"AAuth-Requirement": build_aauth_requirement(
|
|
377
|
-
reason=None, resource_token=token)},
|
|
378
|
-
)
|
|
423
|
+
def _signature_error(self, error: VerificationError) -> Response:
|
|
424
|
+
code = {"revoked_jwt": "AUTH_TOKEN_REVOKED", "expired_jwt": "AUTH_TOKEN_EXPIRED"}.get(
|
|
425
|
+
error.code, "SIGNATURE_INVALID")
|
|
426
|
+
return signature_error_response(error, code=code,
|
|
427
|
+
requirement=_repair_requirement(error))
|
|
428
|
+
|
|
429
|
+
def _revoked(self) -> Response:
|
|
430
|
+
"""The meter knows the token is revoked (the verifier was not wired to
|
|
431
|
+
it, or learned out of band): the same -11 §11.12.5 answer."""
|
|
432
|
+
error = VerificationError("revoked_jwt", "the issuer has withdrawn this token",
|
|
433
|
+
token_typ=AAUTH_AUTH_TYP)
|
|
434
|
+
return self._signature_error(error)
|
|
379
435
|
|
|
380
436
|
async def _refusal_with_token(
|
|
381
437
|
self, *, reason: str, envelope: BudgetClaim,
|