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.
- regent_httpsig/__init__.py +11 -7
- regent_httpsig/budget.py +60 -4
- regent_httpsig/fastapi.py +27 -0
- regent_httpsig/usage.py +48 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.6.0.dist-info}/METADATA +4 -1
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.6.0.dist-info}/RECORD +9 -9
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.6.0.dist-info}/WHEEL +0 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.6.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.6.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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=
|
|
2
|
-
regent_httpsig/budget.py,sha256=
|
|
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
|
|
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=
|
|
10
|
+
regent_httpsig/usage.py,sha256=094CwFHZVDIwmDf8WDg1p4nYlVCBkpi2DrZXdFbFzEo,11889
|
|
11
11
|
regent_httpsig/verify.py,sha256=p9VtHZz9HWHIstDBxNvVrc_xJdubbRA_pN9DjQQDzpk,21716
|
|
12
|
-
regent_httpsig-0.
|
|
13
|
-
regent_httpsig-0.
|
|
14
|
-
regent_httpsig-0.
|
|
15
|
-
regent_httpsig-0.
|
|
16
|
-
regent_httpsig-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|