regent-httpsig 0.5.0__tar.gz → 0.5.1__tar.gz

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.
Files changed (30) hide show
  1. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/CHANGELOG.md +23 -0
  2. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/PKG-INFO +6 -1
  3. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/README.md +5 -0
  4. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/pyproject.toml +1 -1
  5. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/fastapi.py +40 -2
  6. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sfv.py +10 -5
  7. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/verify.py +35 -4
  8. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_budget.py +71 -2
  9. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.github/workflows/ci.yml +0 -0
  10. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.github/workflows/release.yml +0 -0
  11. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/.gitignore +0 -0
  12. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/LICENSE +0 -0
  13. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/SECURITY.md +0 -0
  14. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/examples/fastapi_verify.py +0 -0
  15. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/examples/httpx_signer.py +0 -0
  16. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/__init__.py +0 -0
  17. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/budget.py +0 -0
  18. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/cli.py +0 -0
  19. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/config.py +0 -0
  20. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/jwk.py +0 -0
  21. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/netguard.py +0 -0
  22. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sign.py +0 -0
  23. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/src/regent_httpsig/usage.py +0 -0
  24. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_netguard.py +0 -0
  25. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_signer.py +0 -0
  26. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_usage.py +0 -0
  27. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_vectors.py +0 -0
  28. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/tests/test_verifier.py +0 -0
  29. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/vectors/aauth-budgets-vectors.json +0 -0
  30. {regent_httpsig-0.5.0 → regent_httpsig-0.5.1}/vectors/generate.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ **Budgets — the final-record challenge for expired auth tokens** (draft
6
+ §Budget Exhaustion "auth token expired" + §Settlement). The final/snapshot
7
+ settlement rule needs a moment at which the resource *states* a token's final
8
+ figure — and that moment is the challenge to an expired token. A verifier that
9
+ refuses to look at an expired token can never build it, so the issuer never
10
+ sees a final record and accounts every allocation as fully consumed forever.
11
+
12
+ - `BudgetMiddleware` answers a genuine-but-expired `aa-auth+jwt` (issuer
13
+ signature and proof of possession verified, request signature fresh) with
14
+ `401` + plain `AAuth-Requirement: requirement=auth-token;resource-token=…`
15
+ (no `reason` — the budget didn't run out, the token did) and `code:
16
+ AUTH_TOKEN_EXPIRED`. The resource token carries the presented token's
17
+ `{jti, consumed}`; no `AAuth-Budget` header. Nothing is served or metered.
18
+ - `HttpsigVerifier.verify(..., allow_expired_auth_token=True)` — **opt-in,
19
+ default False**: returns the token with `VerifiedSignature.expired=True`
20
+ for up to `EXPIRED_AUTH_TOKEN_GRACE` (2h, the meter's retention) after
21
+ `exp`. Access decisions through `verify()`/`require_signature` are
22
+ unchanged: an expired token is still `None` there, and the middleware never
23
+ caches an expired result for downstream dependencies.
24
+ - `build_aauth_requirement(reason=None, …)` emits the reason-less challenge.
25
+
3
26
  ## 0.5.0
4
27
 
5
28
  **AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.5.0
3
+ Version: 0.5.1
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,11 @@ 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
+ - An **expired** auth token gets the base protocol's plain challenge with a
178
+ resource token carrying that token's **final** consumption record — the
179
+ figure its issuer needs to settle the allocation (a record stated at or
180
+ after `exp` is final; one on a live token is a snapshot and releases
181
+ nothing).
177
182
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
178
183
  the header, commit when the stream ends (set `request.state.budget_cost`
179
184
  mid-stream if you learn the actual), and the agent recovers the exact cost
@@ -144,6 +144,11 @@ production on [get4agent.com](https://get4agent.com).
144
144
 
145
145
  - `insufficient-budget` refusals carry **`required`** — the refused request's
146
146
  maximum cost — so the agent lowers its bound and retries instead of guessing.
147
+ - An **expired** auth token gets the base protocol's plain challenge with a
148
+ resource token carrying that token's **final** consumption record — the
149
+ figure its issuer needs to settle the allocation (a record stated at or
150
+ after `exp` is final; one on a live token is a snapshot and releases
151
+ nothing).
147
152
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
148
153
  the header, commit when the stream ends (set `request.state.budget_cost`
149
154
  mid-stream if you learn the actual), and the agent recovers the exact cost
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "regent-httpsig"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -191,6 +191,13 @@ class BudgetMiddleware(BaseHTTPMiddleware):
191
191
  return await call_next(request)
192
192
 
193
193
  sig = await self._verified(request)
194
+ if sig is not None and sig.expired:
195
+ # §Budget Exhaustion, "auth token expired": the base protocol's plain
196
+ # 401 — and the resource token we attach carries the presented
197
+ # token's FINAL consumption record (stated at/after its exp), which
198
+ # is what lets its issuer settle the allocation exactly. Without
199
+ # this challenge an issuer never sees a final figure at all.
200
+ return await self._expired_challenge(sig)
194
201
  envelope: BudgetClaim | None = None
195
202
  if sig is not None:
196
203
  try:
@@ -307,11 +314,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
307
314
  result = None
308
315
  if "signature" in request.headers:
309
316
  result = await self._verifier.verify(
310
- request.method, _public_url(request), dict(request.headers)
317
+ request.method, _public_url(request), dict(request.headers),
318
+ allow_expired_auth_token=True,
311
319
  )
312
- request.state.regent_httpsig_result = result
320
+ # An expired token is never handed to the app: the middleware answers
321
+ # it with the final-record challenge and nothing downstream runs. The
322
+ # per-request cache still must not carry it — a later dependency
323
+ # reading the cache would otherwise see a token that is not valid.
324
+ request.state.regent_httpsig_result = (
325
+ None if result is not None and result.expired else result)
313
326
  return result
314
327
 
328
+ async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
329
+ jti = str(sig.claims.get("jti") or "")
330
+ key: MeterKey = (
331
+ str(sig.claims.get("iss", "")),
332
+ str(sig.claims.get("sub", "")),
333
+ str(sig.claims.get("aud", "")),
334
+ )
335
+ token: str | None = None
336
+ if self._resource_token is not None and jti:
337
+ try:
338
+ record = await self._meter.consumed_record(key, jti)
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_EXPIRED",
346
+ "message": "The auth token has expired. Take the resource token in "
347
+ "AAuth-Requirement to your PS for a fresh one.",
348
+ },
349
+ headers={"AAuth-Requirement": build_aauth_requirement(
350
+ reason=None, resource_token=token)},
351
+ )
352
+
315
353
  async def _refusal_with_token(
316
354
  self, *, reason: str, envelope: BudgetClaim,
317
355
  remaining: int, key: MeterKey, jti: str,
@@ -176,13 +176,18 @@ def build_aauth_budget_header(
176
176
  return ", ".join(members)
177
177
 
178
178
 
179
- def build_aauth_requirement(*, reason: str, resource_token: str | None = None) -> str:
180
- """Serialize ``AAuth-Requirement`` for a budget refusal:
179
+ def build_aauth_requirement(*, reason: str | None,
180
+ resource_token: str | None = None) -> str:
181
+ """Serialize ``AAuth-Requirement`` for a budget challenge:
181
182
  ``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
182
- ``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``);
183
- the resource token (when the resource issues one) rides as an sf-string."""
183
+ ``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``)
184
+ for an exhaustion refusal, or ``None`` for the base protocol's plain
185
+ challenge (an EXPIRED auth token — the budget did not run out, the token
186
+ did); the resource token (when the resource issues one) rides as an
187
+ sf-string."""
184
188
  out = "requirement=auth-token"
185
189
  if resource_token:
186
190
  out += f";resource-token={_sf_string(resource_token)}"
187
- out += f";reason={reason}"
191
+ if reason:
192
+ out += f";reason={reason}"
188
193
  return out
@@ -61,6 +61,10 @@ AAUTH_PERSON_TYP = "aa-person+jwt"
61
61
  AAUTH_AUTH_TYP = "aa-auth+jwt" # PS-issued auth tokens — the budget carrier
62
62
  # -11: person and auth tokens live at most one hour — enforced with tolerance.
63
63
  PERSON_TOKEN_MAX_LIFETIME = 3600 + 90
64
+ # How long after its exp an auth token still earns the budgets "final record"
65
+ # challenge (opt-in, BudgetMiddleware only): the meter retains consumption for
66
+ # about this long after last activity; beyond it there is no figure to carry.
67
+ EXPIRED_AUTH_TOKEN_GRACE = 7200
64
68
 
65
69
 
66
70
  def _register_fully_specified_algs() -> None:
@@ -86,6 +90,11 @@ class VerifiedSignature:
86
90
  sub: str | None = None # AAuth agent id (token `sub`)
87
91
  label: str = ""
88
92
  claims: dict[str, Any] = field(default_factory=dict) # AAuth token claims (redacted)
93
+ # Set only when ``verify(..., allow_expired_auth_token=True)`` accepted an
94
+ # auth token PAST its exp: genuine (issuer signature + proof of possession
95
+ # verified) but NOT valid for access. BudgetMiddleware uses it to answer
96
+ # with the final-record challenge; nothing else should ever see it True.
97
+ expired: bool = False
89
98
 
90
99
  def context(self) -> dict[str, Any]:
91
100
  """A flat dict suitable for logging / policy engines / audit trails."""
@@ -164,18 +173,28 @@ class HttpsigVerifier:
164
173
  self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
165
174
 
166
175
  async def verify(
167
- self, method: str, url: str, headers: Mapping[str, str]
176
+ self, method: str, url: str, headers: Mapping[str, str],
177
+ *, allow_expired_auth_token: bool = False,
168
178
  ) -> VerifiedSignature | None:
169
179
  """Verify the request's agent signature. Returns ``None`` when there is
170
180
  no ``Signature`` header, the signature is invalid, or the signer's keys
171
- cannot be (safely) fetched — never raises on untrusted input."""
181
+ cannot be (safely) fetched — never raises on untrusted input.
182
+
183
+ ``allow_expired_auth_token`` (default False — leave it so for access
184
+ decisions) lets a genuine ``aa-auth+jwt`` past its ``exp`` come back
185
+ with ``expired=True`` instead of ``None``, for up to
186
+ :data:`EXPIRED_AUTH_TOKEN_GRACE` seconds. Budgets need this: the
187
+ resource owes the holder of an expired token a challenge carrying that
188
+ token's final consumption record (draft §Budget Exhaustion), and it
189
+ cannot build one for a token it refused to look at."""
172
190
  hdrs = {str(k): str(v) for k, v in headers.items()}
173
191
  if not any(k.lower() == "signature" for k in hdrs):
174
192
  return None
175
193
  result: VerifiedSignature | None = None
176
194
  try:
177
195
  if any(k.lower() == "signature-key" for k in hdrs):
178
- result = await self._verify_aauth(method, url, hdrs)
196
+ result = await self._verify_aauth(
197
+ method, url, hdrs, allow_expired_auth_token=allow_expired_auth_token)
179
198
  if result is None:
180
199
  result = await self._verify_web_bot_auth(method, url, hdrs)
181
200
  except Exception as exc: # noqa: BLE001 — belt and braces
@@ -288,7 +307,8 @@ class HttpsigVerifier:
288
307
  # ── AAuth (identity-based mode) ──────────────────────────────────────────
289
308
 
290
309
  async def _verify_aauth(
291
- self, method: str, url: str, headers: dict[str, str]
310
+ self, method: str, url: str, headers: dict[str, str],
311
+ *, allow_expired_auth_token: bool = False,
292
312
  ) -> VerifiedSignature | None:
293
313
  try:
294
314
  import jwt as pyjwt # the [aauth] extra
@@ -387,6 +407,7 @@ class HttpsigVerifier:
387
407
  continue
388
408
  if issuer_key is None:
389
409
  return None
410
+ tolerate_exp = allow_expired_auth_token and typ == AAUTH_AUTH_TYP
390
411
  try:
391
412
  claims = pyjwt.decode(
392
413
  token,
@@ -396,11 +417,20 @@ class HttpsigVerifier:
396
417
  options={
397
418
  "require": ["iss", "sub", "exp", "iat"],
398
419
  "verify_aud": audience is not None,
420
+ "verify_exp": not tolerate_exp,
399
421
  },
400
422
  )
401
423
  except Exception as exc: # noqa: BLE001
402
424
  logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
403
425
  return None
426
+ expired = False
427
+ if tolerate_exp:
428
+ overdue = int(time.time()) - int(claims.get("exp", 0))
429
+ expired = overdue >= 0
430
+ if overdue > EXPIRED_AUTH_TOKEN_GRACE:
431
+ logger.info("aauth auth token expired %ss ago — past grace iss=%s",
432
+ overdue, iss)
433
+ return None
404
434
 
405
435
  # -11: person and auth tokens live at most one hour.
406
436
  if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
@@ -456,4 +486,5 @@ class HttpsigVerifier:
456
486
  "budget") # budgets: the envelope rides in the token
457
487
  if k in claims
458
488
  },
489
+ expired=expired,
459
490
  )
@@ -266,11 +266,13 @@ def _ps_pair() -> tuple[Ed25519PrivateKey, dict[str, Any]]:
266
266
 
267
267
 
268
268
  def _auth_token(ps_priv: Ed25519PrivateKey, agent: EgressSigner, *,
269
- amount: int, jti: str = "at-1") -> str:
269
+ amount: int, jti: str = "at-1", age: int = 0) -> str:
270
+ """``age`` > 600 mints a token that has ALREADY expired (iat/exp shifted
271
+ into the past) — genuine, possession-bound, not valid for access."""
270
272
  from regent_httpsig.verify import _register_fully_specified_algs
271
273
 
272
274
  _register_fully_specified_algs() # PyJWT knows "Ed25519" only after this
273
- now = int(time.time())
275
+ now = int(time.time()) - age
274
276
  return pyjwt.encode(
275
277
  {
276
278
  "iss": PS_ISS, "sub": "owner-1", "aud": RESOURCE, "jti": jti,
@@ -499,3 +501,70 @@ async def test_streaming_cost_omitted_reserved_math(
499
501
  next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
500
502
  recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
501
503
  assert 700 + 300 - next_remaining == 120 # …prev + reserved − next = cost
504
+
505
+
506
+ async def test_expired_token_gets_final_record_challenge(
507
+ monkeypatch: pytest.MonkeyPatch,
508
+ ) -> None:
509
+ """§Budget Exhaustion, "auth token expired": plain requirement=auth-token
510
+ (no reason) + a resource token carrying the token's FINAL record — the
511
+ figure the issuer needs to settle the allocation (§Settlement: a record
512
+ stated at/after exp is final)."""
513
+ ps_priv, ps_jwk = _ps_pair()
514
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
515
+ live = _auth_token(ps_priv, agent, amount=1000, jti="at-9")
516
+ provider_calls: list[Any] = []
517
+
518
+ def provider(key: Any, record: Any) -> str:
519
+ provider_calls.append((key, record))
520
+ return "resource.token.final"
521
+
522
+ meter = InMemoryMeter()
523
+ app = _app(_verifier(ps_jwk, monkeypatch), meter=meter,
524
+ resource_token_provider=provider)
525
+ # Spend 300 on the live token, so there is a figure to carry.
526
+ r = await _post(app, "/v1/search", _signed_headers(agent, live, "/v1/search"))
527
+ assert r.status_code == 200
528
+
529
+ # The same jti, now past its exp (same issuer, same key, same claims).
530
+ stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
531
+ r = await _post(app, "/v1/search", _signed_headers(agent, stale, "/v1/search"))
532
+ assert r.status_code == 401
533
+ assert r.json()["code"] == "AUTH_TOKEN_EXPIRED"
534
+ assert (r.headers["AAuth-Requirement"]
535
+ == 'requirement=auth-token;resource-token="resource.token.final"')
536
+ assert "AAuth-Budget" not in r.headers # the budget didn't run out; the token did
537
+ assert provider_calls[-1] == ((PS_ISS, "owner-1", RESOURCE),
538
+ {"jti": "at-9", "consumed": 300})
539
+ # Nothing was served and nothing more was metered.
540
+ assert await meter.consumed_record(
541
+ (PS_ISS, "owner-1", RESOURCE), "at-9") == {"jti": "at-9", "consumed": 300}
542
+
543
+
544
+ async def test_expired_token_past_grace_is_no_envelope(
545
+ monkeypatch: pytest.MonkeyPatch,
546
+ ) -> None:
547
+ """Beyond the grace window there is no figure left to carry: the token is
548
+ just an unknown one and the request falls through as unsigned."""
549
+ ps_priv, ps_jwk = _ps_pair()
550
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
551
+ ancient = _auth_token(ps_priv, agent, amount=1000, jti="at-old", age=3 * 3600)
552
+ app = _app(_verifier(ps_jwk, monkeypatch), require=True)
553
+ r = await _post(app, "/v1/search", _signed_headers(agent, ancient, "/v1/search"))
554
+ assert r.status_code == 401 and r.json()["code"] == "AUTH_TOKEN_REQUIRED"
555
+
556
+
557
+ async def test_plain_verify_still_rejects_expired_tokens(
558
+ monkeypatch: pytest.MonkeyPatch,
559
+ ) -> None:
560
+ """The tolerance is opt-in for the budgets middleware only: an access
561
+ decision through ``verify()`` never sees an expired token as verified."""
562
+ ps_priv, ps_jwk = _ps_pair()
563
+ agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
564
+ stale = _auth_token(ps_priv, agent, amount=1000, jti="at-9", age=700)
565
+ verifier = _verifier(ps_jwk, monkeypatch)
566
+ headers = _signed_headers(agent, stale, "/v1/search")
567
+ assert await verifier.verify("POST", f"{RESOURCE}/v1/search", headers) is None
568
+ sig = await verifier.verify("POST", f"{RESOURCE}/v1/search", headers,
569
+ allow_expired_auth_token=True)
570
+ assert sig is not None and sig.expired
File without changes