regent-httpsig 0.5.1__py3-none-any.whl → 0.6.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.
@@ -9,28 +9,30 @@ from regent_httpsig.budget import (
9
9
  InMemoryMeter,
10
10
  InsufficientBudget,
11
11
  InvalidBudgetClaim,
12
+ TokenRevoked,
12
13
  UnitMismatch,
13
14
  )
14
15
  from regent_httpsig.config import HttpsigConfig
15
16
  from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
16
17
  from regent_httpsig.netguard import NotPublicURL, assert_public_url
18
+ from regent_httpsig.sfv import (
19
+ build_aauth_budget_header,
20
+ build_aauth_requirement,
21
+ parse_signature_agent,
22
+ )
23
+ from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
17
24
  from regent_httpsig.usage import (
18
25
  ResponseSigner,
19
26
  UsageQueryError,
20
27
  build_usage_response,
28
+ make_revocation_endpoint,
21
29
  make_usage_endpoint,
22
30
  parse_usage_request,
23
31
  validate_budget_grant,
24
32
  )
25
- from regent_httpsig.sfv import (
26
- build_aauth_budget_header,
27
- build_aauth_requirement,
28
- parse_signature_agent,
29
- )
30
- from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
31
33
  from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
32
34
 
33
- __version__ = "0.3.0"
35
+ __version__ = "0.6.0"
34
36
 
35
37
  __all__ = [
36
38
  "DIRECTORY_MEDIA_TYPE",
@@ -40,6 +42,7 @@ __all__ = [
40
42
  "HttpsigVerifier",
41
43
  "InMemoryMeter",
42
44
  "InsufficientBudget",
45
+ "TokenRevoked",
43
46
  "InvalidBudgetClaim",
44
47
  "NotPublicURL",
45
48
  "UnitMismatch",
@@ -51,6 +54,7 @@ __all__ = [
51
54
  "ResponseSigner",
52
55
  "UsageQueryError",
53
56
  "build_usage_response",
57
+ "make_revocation_endpoint",
54
58
  "make_usage_endpoint",
55
59
  "parse_usage_request",
56
60
  "validate_budget_grant",
regent_httpsig/budget.py CHANGED
@@ -33,8 +33,8 @@ import asyncio
33
33
  import itertools
34
34
  import time
35
35
  from collections.abc import Mapping
36
- from datetime import UTC, datetime, timedelta
37
36
  from dataclasses import dataclass, field
37
+ from datetime import UTC, datetime, timedelta
38
38
  from typing import Any
39
39
 
40
40
  __all__ = [
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "InsufficientBudget",
44
44
  "InvalidBudgetClaim",
45
45
  "Reservation",
46
+ "TokenRevoked",
46
47
  "UnitMismatch",
47
48
  ]
48
49
 
@@ -112,6 +113,17 @@ class InsufficientBudget:
112
113
  exhausted: bool
113
114
 
114
115
 
116
+ @dataclass(frozen=True)
117
+ class TokenRevoked:
118
+ """Refusal: the presented token was revoked (base protocol §Token
119
+ Revocation, budgets §Token Scope). The budget ended with the token — no
120
+ new spend — while requests already in flight complete. ``drained`` says
121
+ whether those have all settled: only then may the resource state the
122
+ token's consumption record, which is then FINAL (AAuth issue #151)."""
123
+
124
+ drained: bool
125
+
126
+
115
127
  @dataclass
116
128
  class _Pool:
117
129
  unit: str
@@ -120,6 +132,7 @@ class _Pool:
120
132
  consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
121
133
  jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
122
134
  reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
135
+ revoked: dict[str, float] = field(default_factory=dict) # jti -> wall time of revocation
123
136
  last_activity: float = 0.0
124
137
 
125
138
 
@@ -178,6 +191,7 @@ class InMemoryMeter:
178
191
  retention_seconds: float = 7200.0,
179
192
  usage_key_retention: float = 86400.0) -> None:
180
193
  self._pools: dict[MeterKey, _Pool] = {}
194
+ self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
181
195
  self._lock = asyncio.Lock()
182
196
  self._rids = itertools.count(1)
183
197
  self._reservation_ttl = reservation_ttl
@@ -190,7 +204,8 @@ class InMemoryMeter:
190
204
  # request" — so a key in continuous use is never pruned.
191
205
  self._usage_key_retention = usage_key_retention
192
206
  self._scope_usage: dict[tuple[str, str], _ScopeCounters] = {} # (iss, sub)
193
- self._key_usage: dict[tuple[str, str], tuple[int, float]] = {} # (iss, jkt) -> (total, last_wall)
207
+ # (iss, jkt) -> (total, last_wall)
208
+ self._key_usage: dict[tuple[str, str], tuple[int, float]] = {}
194
209
  self._metering_unit: tuple[str, int] | None = None
195
210
 
196
211
  # ── internals (call under lock) ──────────────────────────────────────────
@@ -213,9 +228,16 @@ class InMemoryMeter:
213
228
  if (not pool.grants and not pool.reservations
214
229
  and now - pool.last_activity > self._retention):
215
230
  del self._pools[key]
231
+ for pair in [p for p, k in self._jti_index.items() if k == key]:
232
+ del self._jti_index[pair]
216
233
  return None
217
234
  return pool
218
235
 
236
+ @staticmethod
237
+ def _drained(pool: _Pool, jti: str) -> bool:
238
+ """No request is in flight on this token any more."""
239
+ return not any(j == jti for j, _, _ in pool.reservations.values())
240
+
219
241
  @staticmethod
220
242
  def _remaining(pool: _Pool, jti: str) -> int:
221
243
  """The presented token's balance: its grant minus what was committed
@@ -251,14 +273,17 @@ class InMemoryMeter:
251
273
  pool.last_activity = now
252
274
  if jkt:
253
275
  pool.jkt_of.setdefault(jti, jkt)
254
- if jti not in pool.grants and wall_delta > 0:
276
+ self._jti_index.setdefault((key[0], jti), key)
277
+ if jti not in pool.grants and wall_delta > 0 and jti not in pool.revoked:
255
278
  pool.grants[jti] = (claim.amount, now + wall_delta)
256
279
 
257
280
  async def reserve(self, key: MeterKey, jti: str,
258
- max_cost: int) -> Reservation | InsufficientBudget:
281
+ max_cost: int) -> Reservation | InsufficientBudget | TokenRevoked:
259
282
  async with self._lock:
260
283
  now = time.monotonic()
261
284
  pool = self._purge(key, now)
285
+ if pool is not None and jti in pool.revoked:
286
+ return TokenRevoked(drained=self._drained(pool, jti))
262
287
  if pool is None or jti not in pool.grants:
263
288
  return InsufficientBudget(remaining=0, exhausted=True)
264
289
  remaining = self._remaining(pool, jti)
@@ -301,6 +326,33 @@ class InMemoryMeter:
301
326
  pool = self._purge(key, time.monotonic())
302
327
  return 0 if pool is None else self._remaining(pool, jti)
303
328
 
329
+ async def revoke(self, iss: str, jti: str) -> bool:
330
+ """Revoke an auth token by ``(iss, jti)`` — the base protocol's
331
+ revocation identifier. The grant is withdrawn so no new request can
332
+ reserve against it; requests already in flight complete and are
333
+ committed as usual. ``False`` when the pair is unknown here (the
334
+ endpoint answers 404). Idempotent."""
335
+ async with self._lock:
336
+ key = self._jti_index.get((iss, jti))
337
+ if key is None:
338
+ return False
339
+ pool = self._purge(key, time.monotonic())
340
+ if pool is None:
341
+ return False
342
+ pool.revoked.setdefault(jti, time.time())
343
+ pool.grants.pop(jti, None)
344
+ pool.last_activity = time.monotonic()
345
+ return True
346
+
347
+ async def revocation_state(self, key: MeterKey, jti: str) -> TokenRevoked | None:
348
+ """``TokenRevoked`` (with its drain state) if the token was revoked
349
+ here, else ``None``."""
350
+ async with self._lock:
351
+ pool = self._purge(key, time.monotonic())
352
+ if pool is None or jti not in pool.revoked:
353
+ return None
354
+ return TokenRevoked(drained=self._drained(pool, jti))
355
+
304
356
  def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
305
357
  amount: int) -> None:
306
358
  """Post a committed cost to the usage counters (call under lock).
@@ -351,6 +403,10 @@ class InMemoryMeter:
351
403
  pool = self._purge(key, time.monotonic())
352
404
  if pool is None:
353
405
  return None
406
+ if jti in pool.revoked and not self._drained(pool, jti):
407
+ # §Token Scope + AAuth #151: a revoked token's record is FINAL,
408
+ # so it is stated only once nothing is in flight on it.
409
+ return None
354
410
  total = pool.consumed.get(jti, 0)
355
411
  return {"jti": jti, "consumed": total} if total > 0 else None
356
412
 
regent_httpsig/fastapi.py CHANGED
@@ -41,6 +41,7 @@ from regent_httpsig.budget import (
41
41
  InvalidBudgetClaim,
42
42
  MeterKey,
43
43
  Reservation,
44
+ TokenRevoked,
44
45
  UnitMismatch,
45
46
  )
46
47
  from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
@@ -229,6 +230,11 @@ class BudgetMiddleware(BaseHTTPMiddleware):
229
230
  remaining=0, key=key)
230
231
 
231
232
  outcome = await self._meter.reserve(key, jti, int(max_cost))
233
+ if isinstance(outcome, TokenRevoked):
234
+ # §Token Scope: the budget ended with the token. The challenge is
235
+ # the base protocol's plain one; the resource token on it carries
236
+ # the token's FINAL record once nothing is in flight (AAuth #151).
237
+ return await self._revoked_challenge(key, jti, outcome.drained)
232
238
  if isinstance(outcome, InsufficientBudget):
233
239
  reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
234
240
  return await self._refusal_with_token(
@@ -325,6 +331,27 @@ class BudgetMiddleware(BaseHTTPMiddleware):
325
331
  None if result is not None and result.expired else result)
326
332
  return result
327
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
+
328
355
  async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
329
356
  jti = str(sig.claims.get("jti") or "")
330
357
  key: MeterKey = (
regent_httpsig/usage.py CHANGED
@@ -221,3 +221,51 @@ def validate_budget_grant(unit: str, decimals: int,
221
221
  f"(declared in budget_units), got {decimals}")
222
222
  return
223
223
  raise ValueError(f"unit {unit!r} is not declared in budget_units")
224
+
225
+
226
+ def make_revocation_endpoint(
227
+ meter: Any,
228
+ *,
229
+ authenticate_ps: Callable[[Any], Awaitable[str | None]],
230
+ ) -> Callable[[Any], Awaitable[Any]]:
231
+ """The base protocol's revocation endpoint (§Token Revocation) for the
232
+ budgets meter: a signed ``POST`` identifying an auth token by
233
+ ``{"iss", "jti"}`` — both REQUIRED, keyed together because a jti is unique
234
+ only within its issuer. ``200`` when the token was revoked or is already
235
+ invalid here, ``404`` when the pair is not recognized. The body is
236
+ deliberately empty on success: the response must not vary with what the
237
+ recipient holds.
238
+
239
+ ``authenticate_ps`` is the same pinned-PS check the usage endpoint uses.
240
+ The issuer named in the body must be the caller: a PS revokes tokens it
241
+ issued, never another issuer's. What revocation does to the meter is in
242
+ :meth:`InMemoryMeter.revoke` — no new spend, in-flight requests complete,
243
+ and the token's consumption record is withheld until they have (AAuth
244
+ issue #151: a record issued after the revocation is then final)."""
245
+ from starlette.responses import JSONResponse, Response
246
+
247
+ async def handler(request: Any) -> Response:
248
+ caller = await authenticate_ps(request)
249
+ if caller is None:
250
+ return JSONResponse(status_code=401, content={
251
+ "code": "PS_AUTH_REQUIRED",
252
+ "message": "Sign the revocation as the person server that issued the token.",
253
+ })
254
+ try:
255
+ payload = json.loads(await request.body() or b"{}")
256
+ iss, jti = payload.get("iss"), payload.get("jti")
257
+ if not isinstance(iss, str) or not isinstance(jti, str) or not iss or not jti:
258
+ raise ValueError("iss and jti are REQUIRED strings")
259
+ except (ValueError, json.JSONDecodeError) as exc:
260
+ return JSONResponse(status_code=400, content={
261
+ "code": "INVALID_REVOCATION", "message": str(exc)})
262
+ if iss.rstrip("/") != caller.rstrip("/"):
263
+ return JSONResponse(status_code=403, content={
264
+ "code": "NOT_YOUR_TOKEN",
265
+ "message": "A person server may revoke only tokens it issued."})
266
+ if not await meter.revoke(iss, jti):
267
+ return JSONResponse(status_code=404, content={
268
+ "code": "TOKEN_UNKNOWN", "message": "No such (iss, jti) here."})
269
+ return Response(status_code=200)
270
+
271
+ return handler
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.5.1
3
+ Version: 0.6.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
@@ -174,6 +174,9 @@ production on [get4agent.com](https://get4agent.com).
174
174
 
175
175
  - `insufficient-budget` refusals carry **`required`** — the refused request's
176
176
  maximum cost — so the agent lowers its bound and retries instead of guessing.
177
+ - **Revocation**: `make_revocation_endpoint(meter, authenticate_ps=…)` is the
178
+ base protocol's endpoint; a revoked token stops spending at once, in-flight
179
+ requests complete, and its final record rides on the next challenge.
177
180
  - An **expired** auth token gets the base protocol's plain challenge with a
178
181
  resource token carrying that token's **final** consumption record — the
179
182
  figure its issuer needs to settle the allocation (a record stated at or
@@ -1,16 +1,16 @@
1
- regent_httpsig/__init__.py,sha256=Ch-PdsMDo_uKG4U5A4xklU3jbCWdJTWXHG86n5VA7dc,1709
2
- regent_httpsig/budget.py,sha256=sjNJiFyfqLHEdnbh8e4bmqZr2gPveYynZ5eXUYpES_k,16967
1
+ regent_httpsig/__init__.py,sha256=VDo7Yj7LvJ8newfCUpSFZbcncfLFAazMPbyw7MaQQsQ,1809
2
+ regent_httpsig/budget.py,sha256=5yPRdai8ZTLp4lV-cn8y5kbsTtqE06XUhvT3JjJD_Ys,19667
3
3
  regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
4
4
  regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
5
- regent_httpsig/fastapi.py,sha256=RGkEHsNchEac7fXNYs2CT_MOETDktCxHcgQGwytSCCI,17714
5
+ regent_httpsig/fastapi.py,sha256=-JKatY8RCVJm5el5zyPjnhwLbWYYje5iHQO9HpRmLqg,19220
6
6
  regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
7
7
  regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
8
8
  regent_httpsig/sfv.py,sha256=-nUUjyqyAMOSGro9Z0AIXiuchqsIAVaJWbKuIIO4fB0,7624
9
9
  regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
10
- regent_httpsig/usage.py,sha256=PSIPXCtUZyh5cjn4SzwOgrXuJrwDIWu-kffJ-4NFeS0,9453
10
+ regent_httpsig/usage.py,sha256=094CwFHZVDIwmDf8WDg1p4nYlVCBkpi2DrZXdFbFzEo,11889
11
11
  regent_httpsig/verify.py,sha256=p9VtHZz9HWHIstDBxNvVrc_xJdubbRA_pN9DjQQDzpk,21716
12
- regent_httpsig-0.5.1.dist-info/METADATA,sha256=AWYCmTad0KRJFx6ODUZso9fyUcvN6CdU2fbxh0-LTXw,12496
13
- regent_httpsig-0.5.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
- regent_httpsig-0.5.1.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
15
- regent_httpsig-0.5.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
16
- regent_httpsig-0.5.1.dist-info/RECORD,,
12
+ regent_httpsig-0.6.0.dist-info/METADATA,sha256=LBGeQCM6nfJ3f0hcQRTDjQKB2Our30hMhPMmwqCVZXQ,12725
13
+ regent_httpsig-0.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ regent_httpsig-0.6.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
15
+ regent_httpsig-0.6.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
16
+ regent_httpsig-0.6.0.dist-info/RECORD,,