regent-httpsig 0.1.1__py3-none-any.whl → 0.3.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.
@@ -4,26 +4,44 @@ Web Bot Auth (what OpenAI ships, what Cloudflare/AWS/Google verify) and AAuth
4
4
  (draft-hardt), in plain Python. See https://github.com/regent-protocol/regent-httpsig
5
5
  """
6
6
 
7
+ from regent_httpsig.budget import (
8
+ BudgetClaim,
9
+ InMemoryMeter,
10
+ InsufficientBudget,
11
+ InvalidBudgetClaim,
12
+ UnitMismatch,
13
+ )
7
14
  from regent_httpsig.config import HttpsigConfig
8
15
  from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
9
16
  from regent_httpsig.netguard import NotPublicURL, assert_public_url
10
- from regent_httpsig.sfv import parse_signature_agent
17
+ from regent_httpsig.sfv import (
18
+ build_aauth_budget_header,
19
+ build_aauth_requirement,
20
+ parse_signature_agent,
21
+ )
11
22
  from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
12
23
  from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
13
24
 
14
- __version__ = "0.1.1"
25
+ __version__ = "0.3.0"
15
26
 
16
27
  __all__ = [
17
28
  "DIRECTORY_MEDIA_TYPE",
29
+ "BudgetClaim",
18
30
  "EgressSigner",
19
31
  "HttpsigConfig",
20
32
  "HttpsigVerifier",
33
+ "InMemoryMeter",
34
+ "InsufficientBudget",
35
+ "InvalidBudgetClaim",
21
36
  "NotPublicURL",
37
+ "UnitMismatch",
22
38
  "VerifiedSignature",
23
39
  "WBA_TAG",
24
40
  "__version__",
25
41
  "assert_public_url",
26
42
  "b64url",
43
+ "build_aauth_budget_header",
44
+ "build_aauth_requirement",
27
45
  "generate_seed",
28
46
  "jwk_thumbprint",
29
47
  "load_ed25519_jwk",
@@ -0,0 +1,248 @@
1
+ """AAuth Budgets (draft-hardt-aauth-budgets, editor's copy) — resource-side core.
2
+
3
+ The auth token carries a spending envelope::
4
+
5
+ "budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
6
+
7
+ and the resource meters every request against it: reserve the request's maximum
8
+ cost atomically, serve, commit the actual cost, release the difference. The
9
+ draft requires consumption to be aggregated atomically across all live auth
10
+ tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
11
+ of a principal's live tokens and counts reservations + consumption against
12
+ that pool.
13
+
14
+ Everything here is framework-free; the FastAPI glue lives in
15
+ :mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import itertools
22
+ import time
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ __all__ = [
28
+ "BudgetClaim",
29
+ "InMemoryMeter",
30
+ "InsufficientBudget",
31
+ "InvalidBudgetClaim",
32
+ "Reservation",
33
+ "UnitMismatch",
34
+ ]
35
+
36
+ MeterKey = tuple[str, str, str] # (iss, sub, aud) — the draft's aggregation key
37
+
38
+
39
+ class InvalidBudgetClaim(ValueError):
40
+ """A ``budget`` member is present but malformed (issuer bug — not spendable)."""
41
+
42
+
43
+ class UnitMismatch(ValueError):
44
+ """A grant's unit/decimals differ from the pool's — one envelope, one unit."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class BudgetClaim:
49
+ """The ``budget`` claim: integer amount in ``unit`` scaled by ``decimals``.
50
+
51
+ ``amount=5000000, unit="USD", decimals=6`` is $5.00 — all arithmetic stays
52
+ in integers; the scale only matters at display time.
53
+ """
54
+
55
+ amount: int
56
+ unit: str
57
+ decimals: int
58
+
59
+ @staticmethod
60
+ def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
61
+ """Extract the claim from a token's claim set. ``None`` when absent;
62
+ :class:`InvalidBudgetClaim` when present but malformed (all three
63
+ members are REQUIRED, integers must be non-negative, bools are not
64
+ integers here)."""
65
+ raw = claims.get("budget")
66
+ if raw is None:
67
+ return None
68
+ if not isinstance(raw, Mapping):
69
+ raise InvalidBudgetClaim("budget claim must be an object")
70
+ amount, unit, decimals = raw.get("amount"), raw.get("unit"), raw.get("decimals")
71
+ if (
72
+ isinstance(amount, bool) or not isinstance(amount, int) or amount < 0
73
+ or not isinstance(unit, str) or not unit
74
+ or isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0
75
+ ):
76
+ raise InvalidBudgetClaim("budget claim requires amount/unit/decimals")
77
+ return BudgetClaim(amount=amount, unit=unit, decimals=decimals)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Reservation:
82
+ """An atomic hold on the pool for one in-flight request. Never revised —
83
+ committed (with the actual cost) or released, exactly once."""
84
+
85
+ rid: int
86
+ key: MeterKey
87
+ jti: str
88
+ amount: int
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class InsufficientBudget:
93
+ """Refusal: the request's maximum cost exceeds the pool's remaining balance.
94
+ ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
95
+ (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
96
+
97
+ remaining: int
98
+ exhausted: bool
99
+
100
+
101
+ @dataclass
102
+ class _Pool:
103
+ unit: str
104
+ decimals: int
105
+ grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
106
+ consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
107
+ jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
108
+ reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
109
+ last_activity: float = 0.0
110
+
111
+
112
+ class InMemoryMeter:
113
+ """Single-process meter (asyncio-safe). Right for a single-instance service;
114
+ multi-replica deployments need a shared backend behind the same interface.
115
+
116
+ Crash-safety is conservative: a reservation not committed or released within
117
+ ``reservation_ttl`` seconds is treated as fully consumed — the owner's
118
+ envelope is never silently under-counted by a crashed handler.
119
+ """
120
+
121
+ def __init__(self, *, reservation_ttl: float = 120.0,
122
+ retention_seconds: float = 7200.0) -> None:
123
+ self._pools: dict[MeterKey, _Pool] = {}
124
+ self._lock = asyncio.Lock()
125
+ self._rids = itertools.count(1)
126
+ self._reservation_ttl = reservation_ttl
127
+ self._retention = retention_seconds
128
+
129
+ # ── internals (call under lock) ──────────────────────────────────────────
130
+
131
+ def _purge(self, key: MeterKey, now: float) -> _Pool | None:
132
+ pool = self._pools.get(key)
133
+ if pool is None:
134
+ return None
135
+ # Expired, unresolved reservations count as consumed (conservative).
136
+ for rid, (jti, amount, deadline) in list(pool.reservations.items()):
137
+ if deadline <= now:
138
+ pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
139
+ del pool.reservations[rid]
140
+ # Expired grants leave the pool; their consumption records remain for
141
+ # budget_consumed reporting until the retention window passes.
142
+ for jti, (_, exp) in list(pool.grants.items()):
143
+ if exp <= now:
144
+ del pool.grants[jti]
145
+ if (not pool.grants and not pool.reservations
146
+ and now - pool.last_activity > self._retention):
147
+ del self._pools[key]
148
+ return None
149
+ return pool
150
+
151
+ @staticmethod
152
+ def _remaining(pool: _Pool) -> int:
153
+ live = sum(a for a, _ in pool.grants.values())
154
+ spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
155
+ held = sum(a for _, a, _ in pool.reservations.values())
156
+ return max(0, live - spent - held)
157
+
158
+ # ── public interface (the BudgetMeter contract) ──────────────────────────
159
+
160
+ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
161
+ exp: float, jkt: str = "") -> None:
162
+ """Register a token's envelope in the principal's pool (idempotent per
163
+ ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
164
+ recorded so consumption records can be scoped to the presenting agent
165
+ (one agent must not learn about its siblings). Raises
166
+ :class:`UnitMismatch` if the pool already runs in a different unit —
167
+ one envelope, one unit, no FX at the meter."""
168
+ async with self._lock:
169
+ now = time.monotonic()
170
+ wall_delta = exp - time.time()
171
+ pool = self._purge(key, now)
172
+ if pool is None:
173
+ pool = self._pools.setdefault(
174
+ key, _Pool(unit=claim.unit, decimals=claim.decimals))
175
+ if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
176
+ raise UnitMismatch(
177
+ f"pool runs in {pool.unit}/{pool.decimals}, "
178
+ f"grant is {claim.unit}/{claim.decimals}")
179
+ pool.last_activity = now
180
+ if jkt:
181
+ pool.jkt_of.setdefault(jti, jkt)
182
+ if jti not in pool.grants and wall_delta > 0:
183
+ pool.grants[jti] = (claim.amount, now + wall_delta)
184
+
185
+ async def reserve(self, key: MeterKey, jti: str,
186
+ max_cost: int) -> Reservation | InsufficientBudget:
187
+ async with self._lock:
188
+ now = time.monotonic()
189
+ pool = self._purge(key, now)
190
+ if pool is None or jti not in pool.grants:
191
+ return InsufficientBudget(remaining=0, exhausted=True)
192
+ remaining = self._remaining(pool)
193
+ if max_cost > remaining:
194
+ return InsufficientBudget(remaining=remaining,
195
+ exhausted=remaining == 0)
196
+ rid = next(self._rids)
197
+ pool.reservations[rid] = (jti, max_cost, now + self._reservation_ttl)
198
+ pool.last_activity = now
199
+ return Reservation(rid=rid, key=key, jti=jti, amount=max_cost)
200
+
201
+ async def commit(self, res: Reservation, actual: int) -> int:
202
+ """Commit the actual cost (clamped to the reserved amount — reservations
203
+ are never revised upward) and return the pool's remaining balance."""
204
+ async with self._lock:
205
+ now = time.monotonic()
206
+ pool = self._purge(res.key, now)
207
+ if pool is None:
208
+ return 0
209
+ held = pool.reservations.pop(res.rid, None)
210
+ cost = min(max(actual, 0), held[1] if held else res.amount)
211
+ pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
212
+ pool.last_activity = now
213
+ return self._remaining(pool)
214
+
215
+ async def release(self, res: Reservation) -> int:
216
+ async with self._lock:
217
+ pool = self._purge(res.key, time.monotonic())
218
+ if pool is None:
219
+ return 0
220
+ pool.reservations.pop(res.rid, None)
221
+ return self._remaining(pool)
222
+
223
+ async def remaining(self, key: MeterKey) -> int:
224
+ async with self._lock:
225
+ pool = self._purge(key, time.monotonic())
226
+ return 0 if pool is None else self._remaining(pool)
227
+
228
+ async def consumed_records(self, key: MeterKey,
229
+ jkt: str | None = None) -> list[dict[str, Any]]:
230
+ """Per-token consumption for the resource token's ``budget_consumed``
231
+ claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
232
+ PS deduplicates by ``jti``, so reporting the same record twice is safe.
233
+
234
+ When ``jkt`` is given, records are scoped to tokens bound to that key:
235
+ the agent carrying the resource token sees only its OWN spending, never
236
+ its siblings' (privacy between a principal's agents, and no extra
237
+ figures to infer the ceiling from). Consequence: an abandoned agent's
238
+ records are never carried home by siblings — the PS-side conservative
239
+ rule (unreported expired allocation = fully consumed) is the backstop."""
240
+ async with self._lock:
241
+ pool = self._purge(key, time.monotonic())
242
+ if pool is None:
243
+ return []
244
+ return [
245
+ {"jti": jti, "consumed": total}
246
+ for jti, total in sorted(pool.consumed.items())
247
+ if total > 0 and (jkt is None or pool.jkt_of.get(jti) == jkt)
248
+ ]
regent_httpsig/config.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
5
6
  from dataclasses import dataclass, field
6
7
 
7
8
  __all__ = ["HttpsigConfig"]
@@ -30,3 +31,17 @@ class HttpsigConfig:
30
31
  # Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
31
32
  # e.g. frozenset({"localhost"})). Leave empty in production.
32
33
  insecure_hosts: frozenset[str] = field(default_factory=frozenset)
34
+ # AAuth -11 (editor's copy): JOSE algs must be fully-specified per RFC 9864 —
35
+ # implementations MUST NOT accept the polymorphic "EdDSA". True enforces that;
36
+ # the False default keeps accepting "EdDSA" while the -10 ecosystem migrates.
37
+ require_fully_specified_algs: bool = False
38
+ # This service's public URL (e.g. "https://api.example"). Required to accept
39
+ # AAuth person tokens — their `aud` must name this resource. None disables
40
+ # the person-token path entirely.
41
+ resource_url: str | None = None
42
+ # AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes):
43
+ # issuer → JWKS URL for each Person Server this resource accepts auth tokens
44
+ # from. A resource has an established relationship with its PS, so the key
45
+ # location is pinned by configuration rather than discovered open-world.
46
+ # Empty (the default) disables the auth-token path entirely.
47
+ trusted_ps: Mapping[str, str] = field(default_factory=dict)
regent_httpsig/fastapi.py CHANGED
@@ -25,13 +25,37 @@ $scheme;``), or verification will fail on the scheme mismatch.
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- from fastapi import Depends, FastAPI, HTTPException, Request
28
+ import inspect
29
+ import logging
30
+ from collections.abc import Awaitable, Callable
31
+ from typing import Any
29
32
 
33
+ from fastapi import Depends, FastAPI, HTTPException, Request, Response
34
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
35
+ from starlette.responses import JSONResponse
36
+
37
+ from regent_httpsig.budget import (
38
+ BudgetClaim,
39
+ InMemoryMeter,
40
+ InsufficientBudget,
41
+ InvalidBudgetClaim,
42
+ MeterKey,
43
+ Reservation,
44
+ UnitMismatch,
45
+ )
46
+ from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
30
47
  from regent_httpsig.verify import HttpsigVerifier, VerifiedSignature
31
48
 
32
- __all__ = ["RequiredSignatureDep", "SignatureDep", "VerifiedSignature", "attach"]
49
+ __all__ = [
50
+ "BudgetMiddleware",
51
+ "RequiredSignatureDep",
52
+ "SignatureDep",
53
+ "VerifiedSignature",
54
+ "attach",
55
+ ]
33
56
 
34
57
  _STATE_ATTR = "regent_httpsig_verifier"
58
+ logger = logging.getLogger("regent_httpsig")
35
59
 
36
60
 
37
61
  def attach(app: FastAPI, verifier: HttpsigVerifier) -> None:
@@ -92,3 +116,192 @@ async def require_signature(request: Request) -> VerifiedSignature:
92
116
 
93
117
  SignatureDep = Depends(get_signature)
94
118
  RequiredSignatureDep = Depends(require_signature)
119
+
120
+
121
+ # ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
122
+
123
+ PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
124
+ ResourceTokenProvider = Callable[
125
+ [MeterKey, "list[dict[str, Any]]"], "str | None | Awaitable[str | None]"
126
+ ]
127
+
128
+
129
+ async def _maybe_await(value: Any) -> Any:
130
+ return await value if inspect.isawaitable(value) else value
131
+
132
+
133
+ class BudgetMiddleware(BaseHTTPMiddleware):
134
+ """Meter budget-carrying requests: reserve the maximum cost atomically,
135
+ serve, commit the actual, release the difference, and answer with an
136
+ ``AAuth-Budget`` header. The full checklist a resource owes the draft —
137
+ pricing excepted, which is the one thing only the resource can know.
138
+
139
+ Usage::
140
+
141
+ meter = InMemoryMeter()
142
+ app.add_middleware(
143
+ BudgetMiddleware,
144
+ verifier=HttpsigVerifier(HttpsigConfig(
145
+ resource_url="https://api.example",
146
+ trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
147
+ )),
148
+ meter=meter,
149
+ price_fn=lambda request: PRICES.get(request.url.path),
150
+ )
151
+
152
+ - ``price_fn(request)`` returns the request's MAXIMUM cost in the envelope's
153
+ minor units, or ``None`` for routes outside budget enforcement.
154
+ - A handler that knows the actual cost sets ``request.state.budget_cost``
155
+ before returning; otherwise the full reservation is committed.
156
+ - ``require=False`` (default) lets requests without a budget envelope pass
157
+ through untouched — run per-decision authorization for them instead.
158
+ ``require=True`` refuses them with 401 + ``AAuth-Requirement``.
159
+ - Error responses (4xx/5xx) release the reservation — nothing was served,
160
+ the envelope is not charged.
161
+ - ``resource_token_provider(key, consumed_records)`` (optional) mints the
162
+ resource token embedded in budget-refusal responses so the agent can
163
+ carry ``budget_consumed`` back to its PS for re-authorization.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ app: Any,
169
+ *,
170
+ verifier: HttpsigVerifier,
171
+ meter: InMemoryMeter | Any = None,
172
+ price_fn: PriceFn,
173
+ require: bool = False,
174
+ resource_token_provider: ResourceTokenProvider | None = None,
175
+ ) -> None:
176
+ super().__init__(app)
177
+ self._verifier = verifier
178
+ self._meter = meter if meter is not None else InMemoryMeter()
179
+ self._price_fn = price_fn
180
+ self._require = require
181
+ self._resource_token = resource_token_provider
182
+
183
+ async def dispatch(
184
+ self, request: Request, call_next: RequestResponseEndpoint
185
+ ) -> Response:
186
+ max_cost = await _maybe_await(self._price_fn(request))
187
+ if max_cost is None:
188
+ return await call_next(request)
189
+
190
+ sig = await self._verified(request)
191
+ envelope: BudgetClaim | None = None
192
+ if sig is not None:
193
+ try:
194
+ envelope = BudgetClaim.parse(sig.claims)
195
+ except InvalidBudgetClaim as exc:
196
+ logger.warning("malformed budget claim from %s: %s", sig.agent, exc)
197
+ jti = str(sig.claims.get("jti") or "") if sig else ""
198
+
199
+ if sig is None or envelope is None or not jti:
200
+ if self._require:
201
+ return self._refusal(reason=None, envelope=None, remaining=None)
202
+ return await call_next(request) # per-decision path handles it
203
+
204
+ key: MeterKey = (
205
+ str(sig.claims.get("iss", "")),
206
+ str(sig.claims.get("sub", "")),
207
+ str(sig.claims.get("aud", "")),
208
+ )
209
+ try:
210
+ # sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
211
+ # recorded so refusal-time consumption records are scoped to the
212
+ # presenting agent and never disclose its siblings' spending.
213
+ await self._meter.observe_grant(key, jti, envelope,
214
+ float(sig.claims.get("exp", 0)),
215
+ jkt=sig.keyid)
216
+ except UnitMismatch as exc:
217
+ logger.warning("budget unit mismatch for %s: %s", key, exc)
218
+ return self._refusal(reason="insufficient-budget", envelope=envelope,
219
+ remaining=0, key=key)
220
+
221
+ outcome = await self._meter.reserve(key, jti, int(max_cost))
222
+ if isinstance(outcome, InsufficientBudget):
223
+ reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
224
+ return await self._refusal_with_token(
225
+ reason=reason, envelope=envelope, remaining=outcome.remaining,
226
+ key=key, jkt=sig.keyid,
227
+ )
228
+
229
+ reservation: Reservation = outcome
230
+ try:
231
+ response = await call_next(request)
232
+ except Exception:
233
+ await self._meter.release(reservation)
234
+ raise
235
+
236
+ if response.status_code >= 400:
237
+ # Nothing was served — the envelope is not charged for errors.
238
+ remaining = await self._meter.release(reservation)
239
+ cost = 0
240
+ else:
241
+ actual = getattr(request.state, "budget_cost", None)
242
+ cost = int(actual) if actual is not None else int(max_cost)
243
+ remaining = await self._meter.commit(reservation, cost)
244
+ response.headers["AAuth-Budget"] = build_aauth_budget_header(
245
+ remaining=remaining, cost=cost,
246
+ unit=envelope.unit, decimals=envelope.decimals,
247
+ )
248
+ return response
249
+
250
+ # ── helpers ──────────────────────────────────────────────────────────────
251
+
252
+ async def _verified(self, request: Request) -> VerifiedSignature | None:
253
+ cached = getattr(request.state, "regent_httpsig_result", "unset")
254
+ if cached != "unset":
255
+ return cached # type: ignore[return-value]
256
+ result = None
257
+ if "signature" in request.headers:
258
+ result = await self._verifier.verify(
259
+ request.method, _public_url(request), dict(request.headers)
260
+ )
261
+ request.state.regent_httpsig_result = result
262
+ return result
263
+
264
+ async def _refusal_with_token(
265
+ self, *, reason: str, envelope: BudgetClaim,
266
+ remaining: int, key: MeterKey, jkt: str | None = None,
267
+ ) -> Response:
268
+ token: str | None = None
269
+ if self._resource_token is not None:
270
+ try:
271
+ records = await self._meter.consumed_records(key, jkt=jkt)
272
+ token = await _maybe_await(self._resource_token(key, records))
273
+ except Exception: # noqa: BLE001 — refusal must not fail on the extras
274
+ logger.warning("resource_token_provider failed", exc_info=True)
275
+ return self._refusal(reason=reason, envelope=envelope,
276
+ remaining=remaining, resource_token=token)
277
+
278
+ def _refusal(
279
+ self, *, reason: str | None, envelope: BudgetClaim | None,
280
+ remaining: int | None, key: MeterKey | None = None,
281
+ resource_token: str | None = None,
282
+ ) -> Response:
283
+ headers = {
284
+ "AAuth-Requirement": build_aauth_requirement(
285
+ reason=reason or "insufficient-budget",
286
+ resource_token=resource_token,
287
+ )
288
+ }
289
+ if remaining is not None and envelope is not None:
290
+ headers["AAuth-Budget"] = build_aauth_budget_header(
291
+ remaining=remaining, unit=envelope.unit, decimals=envelope.decimals
292
+ )
293
+ code = "AUTH_TOKEN_REQUIRED" if reason is None else reason.upper().replace("-", "_")
294
+ return JSONResponse(
295
+ status_code=401,
296
+ content={
297
+ "code": code,
298
+ "message": (
299
+ "Present an auth token with a budget envelope "
300
+ "(AAuth Budgets) to call this endpoint."
301
+ if reason is None else
302
+ "The request's maximum cost exceeds the envelope's remaining "
303
+ "balance. Re-authorize with your PS for a fresh auth token."
304
+ ),
305
+ },
306
+ headers=headers,
307
+ )
regent_httpsig/sfv.py CHANGED
@@ -32,6 +32,8 @@ __all__ = [
32
32
  "SFDictionary",
33
33
  "SFItem",
34
34
  "StaticKeyResolver",
35
+ "build_aauth_budget_header",
36
+ "build_aauth_requirement",
35
37
  "parse_signature_agent",
36
38
  "parse_signature_key_header",
37
39
  ]
@@ -130,3 +132,51 @@ def parse_signature_key_header(value: str) -> tuple[str, str] | None:
130
132
  except Exception: # noqa: BLE001
131
133
  return None
132
134
  return None
135
+
136
+
137
+ # ── AAuth Budgets response headers (draft-hardt-aauth-budgets) ───────────────
138
+ # Hand-serialized: the value space is tiny (non-negative sf-integers, one
139
+ # sf-string, sf-tokens) and the golden tests round-trip the output through the
140
+ # real http_sfv parser. NOTE: the field is declared an RFC 9651 *Dictionary*,
141
+ # so members are comma-separated — the draft's §11 example shows semicolons,
142
+ # which is the *parameter* separator; flagged for the implementation report.
143
+
144
+
145
+ def _sf_string(value: str) -> str:
146
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
147
+
148
+
149
+ def build_aauth_budget_header(
150
+ *,
151
+ remaining: int,
152
+ cost: int | None = None,
153
+ reserved: int | None = None,
154
+ unit: str | None = None,
155
+ decimals: int | None = None,
156
+ ) -> str:
157
+ """Serialize the ``AAuth-Budget`` response header. ``remaining`` is the only
158
+ REQUIRED member; ``unit``/``decimals`` must travel together or not at all."""
159
+ if (unit is None) != (decimals is None):
160
+ raise ValueError("unit and decimals must be provided together")
161
+ members: list[str] = []
162
+ if cost is not None:
163
+ members.append(f"cost={cost}")
164
+ members.append(f"remaining={remaining}")
165
+ if reserved is not None:
166
+ members.append(f"reserved={reserved}")
167
+ if unit is not None and decimals is not None:
168
+ members.append(f"unit={_sf_string(unit)}")
169
+ members.append(f"decimals={decimals}")
170
+ return ", ".join(members)
171
+
172
+
173
+ def build_aauth_requirement(*, reason: str, resource_token: str | None = None) -> str:
174
+ """Serialize ``AAuth-Requirement`` for a budget refusal:
175
+ ``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
176
+ ``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``);
177
+ the resource token (when the resource issues one) rides as an sf-string."""
178
+ out = "requirement=auth-token"
179
+ if resource_token:
180
+ out += f";resource-token={_sf_string(resource_token)}"
181
+ out += f";reason={reason}"
182
+ return out
regent_httpsig/verify.py CHANGED
@@ -55,7 +55,24 @@ logger = logging.getLogger("regent_httpsig")
55
55
  WBA_TAG = "web-bot-auth"
56
56
  WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
57
57
  AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
58
+ AAUTH_PERSON_METADATA_PATH = "/.well-known/aauth-person.json"
58
59
  AAUTH_JWT_TYP = "aa-agent+jwt"
60
+ 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
+
65
+
66
+ def _register_fully_specified_algs() -> None:
67
+ """Register 'Ed25519' (RFC 9864 fully-specified) with PyJWT — same math as
68
+ the polymorphic 'EdDSA', which AAuth -11 forbids implementations to accept."""
69
+ import contextlib
70
+
71
+ import jwt as pyjwt
72
+ from jwt.algorithms import OKPAlgorithm
73
+
74
+ with contextlib.suppress(ValueError): # already registered = fine
75
+ pyjwt.register_algorithm("Ed25519", OKPAlgorithm())
59
76
 
60
77
 
61
78
  @dataclass
@@ -286,25 +303,72 @@ class HttpsigVerifier:
286
303
  return None
287
304
  label, token = parsed
288
305
 
306
+ _register_fully_specified_algs()
289
307
  try:
290
308
  header = pyjwt.get_unverified_header(token)
291
309
  unverified = pyjwt.decode(token, options={"verify_signature": False})
292
310
  except Exception: # noqa: BLE001
293
311
  return None
294
- if header.get("typ") != AAUTH_JWT_TYP or header.get("alg") in (None, "none"):
295
- return None
296
- iss = str(unverified.get("iss", ""))
297
- bad_iss = unverified.get("dwk") != "aauth-agent.json" or not iss.startswith("https://")
298
- if bad_iss and not (
299
- iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
300
- ):
312
+ if header.get("alg") in (None, "none"):
301
313
  return None
302
314
 
303
- # 1) Verify the agent_token against the issuer's published JWKS.
304
- metadata = await self._fetch_json(iss.rstrip("/") + AAUTH_METADATA_PATH)
305
- if not metadata or not metadata.get("jwks_uri"):
315
+ # -11 token-type dispatch: agent tokens (identity mode), person tokens
316
+ # (PS-issued, per-resource, opt-in via config.resource_url) and auth
317
+ # tokens (PS-issued budget carriers, opt-in via config.trusted_ps).
318
+ typ = header.get("typ")
319
+ jwks_override: str | None = None
320
+ if typ == AAUTH_JWT_TYP:
321
+ scheme, expected_dwk = "aauth", "aauth-agent.json"
322
+ metadata_path, audience = AAUTH_METADATA_PATH, None
323
+ elif typ == AAUTH_PERSON_TYP:
324
+ if not self.config.resource_url:
325
+ logger.info("person token presented but config.resource_url is not "
326
+ "set — person-token verification is disabled")
327
+ return None
328
+ scheme, expected_dwk = "aauth-person", "aauth-person.json"
329
+ metadata_path, audience = AAUTH_PERSON_METADATA_PATH, self.config.resource_url
330
+ elif typ == AAUTH_AUTH_TYP:
331
+ if not self.config.resource_url or not self.config.trusted_ps:
332
+ logger.info("auth token presented but resource_url/trusted_ps is not "
333
+ "configured — auth-token verification is disabled")
334
+ return None
335
+ scheme, expected_dwk = "aauth-auth", None
336
+ metadata_path, audience = None, self.config.resource_url
337
+ else:
306
338
  return None
307
- jwks = await self._fetch_json(str(metadata["jwks_uri"]))
339
+
340
+ iss = str(unverified.get("iss", ""))
341
+ if typ == AAUTH_AUTH_TYP:
342
+ # The resource pins its PS: issuer must be explicitly trusted and its
343
+ # JWKS location comes from configuration, not open-world discovery.
344
+ override = self.config.trusted_ps.get(iss)
345
+ if override is None:
346
+ logger.info("auth token issuer %s is not a configured PS", iss[:100])
347
+ return None
348
+ jwks_override = override
349
+ else:
350
+ bad_iss = (unverified.get("dwk") != expected_dwk
351
+ or not iss.startswith("https://"))
352
+ if bad_iss and not (
353
+ iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
354
+ ):
355
+ return None
356
+
357
+ # AAuth -11 / RFC 9864: fully-specified algorithms. "EdDSA" (polymorphic)
358
+ # is accepted only while require_fully_specified_algs is False — a
359
+ # transition affordance for the -10 ecosystem.
360
+ allowed_algs = ["Ed25519", "ES256", "RS256"]
361
+ if not self.config.require_fully_specified_algs:
362
+ allowed_algs.append("EdDSA")
363
+
364
+ # 1) Verify the token against the issuer's published JWKS.
365
+ if jwks_override is not None:
366
+ jwks = await self._fetch_json(jwks_override)
367
+ else:
368
+ metadata = await self._fetch_json(iss.rstrip("/") + str(metadata_path))
369
+ if not metadata or not metadata.get("jwks_uri"):
370
+ return None
371
+ jwks = await self._fetch_json(str(metadata["jwks_uri"]))
308
372
  if not jwks:
309
373
  return None
310
374
  issuer_key = None
@@ -314,24 +378,46 @@ class HttpsigVerifier:
314
378
  issuer_key = pyjwt.PyJWK(k).key
315
379
  break
316
380
  except Exception: # noqa: BLE001
317
- continue
381
+ # PyJWK's internal registry predates RFC 9864 names — a JWKS
382
+ # advertising alg "Ed25519" is valid in -11 but unknown to it.
383
+ try:
384
+ issuer_key = load_ed25519_jwk(k)
385
+ break
386
+ except ValueError:
387
+ continue
318
388
  if issuer_key is None:
319
389
  return None
320
390
  try:
321
391
  claims = pyjwt.decode(
322
392
  token,
323
393
  key=issuer_key,
324
- algorithms=["EdDSA", "ES256", "RS256"],
325
- options={"require": ["iss", "sub", "exp", "iat"]},
394
+ algorithms=allowed_algs,
395
+ audience=audience,
396
+ options={
397
+ "require": ["iss", "sub", "exp", "iat"],
398
+ "verify_aud": audience is not None,
399
+ },
326
400
  )
327
401
  except Exception as exc: # noqa: BLE001
328
402
  logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
329
403
  return None
330
404
 
405
+ # -11: person and auth tokens live at most one hour.
406
+ if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
407
+ lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
408
+ if lifetime <= 0 or lifetime > PERSON_TOKEN_MAX_LIFETIME:
409
+ logger.info("%s lifetime %ss out of bounds iss=%s", typ, lifetime, iss)
410
+ return None
411
+
331
412
  # 2) Proof of possession: the request signature must verify against cnf.jwk.
332
413
  cnf_jwk = (claims.get("cnf") or {}).get("jwk")
333
414
  if not isinstance(cnf_jwk, dict):
334
415
  return None
416
+ # -11 strict mode: the cnf JWK "MUST carry a fully-specified alg member".
417
+ if self.config.require_fully_specified_algs and cnf_jwk.get("alg") != "Ed25519":
418
+ logger.info("cnf.jwk alg %r is not fully-specified iss=%s",
419
+ cnf_jwk.get("alg"), iss)
420
+ return None
335
421
  try:
336
422
  pop_key = load_ed25519_jwk(cnf_jwk)
337
423
  except ValueError:
@@ -357,11 +443,17 @@ class HttpsigVerifier:
357
443
  return None
358
444
 
359
445
  return VerifiedSignature(
360
- scheme="aauth",
446
+ scheme=scheme,
361
447
  agent=iss,
362
448
  keyid=jwk_thumbprint(cnf_jwk),
363
- trusted=iss in self.config.trusted_agents,
449
+ # An auth-token issuer is by definition a configured, trusted PS.
450
+ trusted=iss in self.config.trusted_agents or typ == AAUTH_AUTH_TYP,
364
451
  sub=str(claims.get("sub", "")),
365
452
  label=label,
366
- claims={k: claims[k] for k in ("iss", "sub", "exp", "ps") if k in claims},
453
+ claims={
454
+ k: claims[k]
455
+ for k in ("iss", "sub", "exp", "ps", "aud", "jti", "mission_s256",
456
+ "budget") # budgets: the envelope rides in the token
457
+ if k in claims
458
+ },
367
459
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
5
5
  Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
6
6
  Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
@@ -72,6 +72,12 @@ signature yields `None`, and nothing ever raises on untrusted input. Use
72
72
  `regent_httpsig.fastapi.RequiredSignatureDep` when a signature must be present — the 401
73
73
  tells the agent exactly how to sign.
74
74
 
75
+ > **Behind a reverse proxy?** The agent signed the *public* URL
76
+ > (`https://api.example/…`), but your ASGI server sees `http://container/…`. The FastAPI
77
+ > dependency rebuilds the signed URL from `X-Forwarded-Proto` + `Host`, so make sure your
78
+ > proxy forwards the scheme — nginx: `proxy_set_header X-Forwarded-Proto $scheme;`.
79
+ > If signatures mysteriously fail to verify in production, check this first.
80
+
75
81
  ## Sign: get your agent past bot walls
76
82
 
77
83
  ```python
@@ -125,10 +131,43 @@ keyid-less shape is pinned in CI.
125
131
  - **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
126
132
  JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
127
133
  `cnf.jwk` verifies the request signature. Install with `pip install 'regent-httpsig[aauth]'`.
134
+ Tracks the **-11 editor's copy**: fully-specified algorithms (RFC 9864, `Ed25519` — with a
135
+ transition flag for the -10 ecosystem's `EdDSA`) and **person tokens** (`aa-person+jwt`,
136
+ opt-in via `HttpsigConfig.resource_url`).
128
137
  For a full-protocol AAuth implementation (both roles, all token types) see
129
138
  [christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
130
139
  this library is the thin relying-party verifier that handles both dialects.
131
140
 
141
+ ## Budgets: meter a spending envelope (draft-hardt-aauth-budgets)
142
+
143
+ An agent can carry a PS-issued **auth token** (`typ: aa-auth+jwt`) with a
144
+ `budget` claim — a spending envelope it uses offline, no per-call round trip
145
+ to the control plane. The middleware does the whole resource-side checklist:
146
+ verify the token against your pinned PS, atomically reserve → commit →
147
+ release per request, answer with `AAuth-Budget`, and refuse exhausted
148
+ envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
149
+ resource token with the agent's own consumption records — scoped to its key,
150
+ so one agent never learns about a sibling's spending):
151
+
152
+ ```python
153
+ from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
154
+ from regent_httpsig.fastapi import BudgetMiddleware
155
+
156
+ app.add_middleware(
157
+ BudgetMiddleware,
158
+ verifier=HttpsigVerifier(HttpsigConfig(
159
+ resource_url="https://api.example",
160
+ trusted_ps={"my-ps": "https://ps.example/jwks.json"},
161
+ )),
162
+ meter=InMemoryMeter(),
163
+ price_fn=lambda request: PRICES.get(request.url.path), # max cost, minor units
164
+ )
165
+ ```
166
+
167
+ The only thing the library cannot do for you is pricing (`price_fn`) — that
168
+ is your domain. First known implementation of the draft; running in
169
+ production on [get4agent.com](https://get4agent.com).
170
+
132
171
  ## Security model (what a naive implementation gets wrong)
133
172
 
134
173
  The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
@@ -0,0 +1,15 @@
1
+ regent_httpsig/__init__.py,sha256=TCRliXblszIoUdlNNQzPf41jA2lZ8Pu3OImylFGzaMk,1372
2
+ regent_httpsig/budget.py,sha256=8B0Zb5exfjorP1pcvezpuYqHN13ytrwJG3MNOEhnvE4,10563
3
+ regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
4
+ regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
5
+ regent_httpsig/fastapi.py,sha256=cDOk7qHdz58JO3MlTDLsyDwGEQm6t3ZO3S3hyyo7OyU,12550
6
+ regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
7
+ regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
8
+ regent_httpsig/sfv.py,sha256=zTxPf2f0XdSu3yKl3rw_CV4_Gbpl1ZJzOM_IA9nM7H0,7076
9
+ regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
10
+ regent_httpsig/verify.py,sha256=8z-x3trF5B8P8D1aDYoqzF4R5h8BVv91CKiubFKFIiI,19915
11
+ regent_httpsig-0.3.0.dist-info/METADATA,sha256=T0gBVK2_fZOYuWj_5ZlIYH5WJk7nn9RbmG-uWAFzcqM,10439
12
+ regent_httpsig-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ regent_httpsig-0.3.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
14
+ regent_httpsig-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
15
+ regent_httpsig-0.3.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- regent_httpsig/__init__.py,sha256=xtujR5LyjhCFZ9uDuSZJoHTmM9PQglcCr5_AmiSTHNs,987
2
- regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
3
- regent_httpsig/config.py,sha256=xkILlWugaD8Nn4huAgL6RP-C916KxCYRn0YpqCkMOGc,1422
4
- regent_httpsig/fastapi.py,sha256=YmfrKdeMft2vpuiRlivIEHYC4BIs3ioR7T3t1ZEzcJ4,3707
5
- regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
6
- regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
7
- regent_httpsig/sfv.py,sha256=kcQsBLw9h4h_6D3z2rv0-3_gLZQYC47B9VZi-oS_Vgk,4975
8
- regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
9
- regent_httpsig/verify.py,sha256=lGmGqlUKXtMlEDwoAanUZcz98XpPnY6rDe7CfuMXIiQ,15434
10
- regent_httpsig-0.1.1.dist-info/METADATA,sha256=BePwZKhmLOt-Obf4Y3H-I1VWvXUM4juOiOI5w6tvki4,8488
11
- regent_httpsig-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
- regent_httpsig-0.1.1.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
13
- regent_httpsig-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
14
- regent_httpsig-0.1.1.dist-info/RECORD,,