regent-httpsig 0.4.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.4.0 → regent_httpsig-0.5.1}/CHANGELOG.md +47 -0
  2. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/PKG-INFO +10 -3
  3. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/README.md +9 -2
  4. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/pyproject.toml +1 -1
  5. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/budget.py +60 -27
  6. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/fastapi.py +55 -11
  7. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sfv.py +10 -5
  8. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/verify.py +35 -4
  9. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_budget.py +99 -18
  10. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_usage.py +1 -1
  11. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/vectors/aauth-budgets-vectors.json +8 -13
  12. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/vectors/generate.py +9 -8
  13. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/.github/workflows/ci.yml +0 -0
  14. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/.github/workflows/release.yml +0 -0
  15. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/.gitignore +0 -0
  16. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/LICENSE +0 -0
  17. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/SECURITY.md +0 -0
  18. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/examples/fastapi_verify.py +0 -0
  19. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/examples/httpx_signer.py +0 -0
  20. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/__init__.py +0 -0
  21. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/cli.py +0 -0
  22. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/config.py +0 -0
  23. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/jwk.py +0 -0
  24. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/netguard.py +0 -0
  25. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/sign.py +0 -0
  26. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/src/regent_httpsig/usage.py +0 -0
  27. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_netguard.py +0 -0
  28. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_signer.py +0 -0
  29. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_vectors.py +0 -0
  30. {regent_httpsig-0.4.0 → regent_httpsig-0.5.1}/tests/test_verifier.py +0 -0
@@ -1,5 +1,52 @@
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
+
26
+ ## 0.5.0
27
+
28
+ **AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets
29
+ editor's copy, September 2026; issue #120):
30
+
31
+ - **No more pooling.** `InMemoryMeter` caps each auth token at its own `budget`
32
+ (§Aggregation): committed consumption plus outstanding reservations against
33
+ the presented `jti` never exceed *its* grant, and a token never draws on a
34
+ sibling's allocation. The `(iss, sub, aud)` key survives as the per-person
35
+ **ledger** for records and usage counters — it is not a second ceiling.
36
+ 0.4 pooled a person's live grants into one purse, which let a jti spend past
37
+ its own grant; the overflow, spent from a sibling's allocation, was never
38
+ attributed and the sibling's remainder later released as unspent. The
39
+ `required` member makes a fragmented agent's re-authorization a calculation,
40
+ so the purse bought nothing worth that.
41
+ - **One record on the wire.** Budget refusals carry a single
42
+ `budget_consumed` object — `{"jti", "consumed"}` for the PRESENTED token —
43
+ per §The Consumption Record. New `InMemoryMeter.consumed_record(key, jti)`;
44
+ `consumed_records(key, jkt=…)` stays as the audit view.
45
+ - **Breaking:** `InMemoryMeter.remaining(key)` → `remaining(key, jti)`;
46
+ `resource_token_provider(key, records: list)` → `(key, record: dict | None)`.
47
+ `InsufficientBudget.remaining` is now the presented token's balance.
48
+ - Test vectors regenerated (`consumption_records` is one object).
49
+
3
50
  ## 0.4.0
4
51
 
5
52
  **AAuth Budgets — the August 20 editor's-copy additions** (allocation model,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.4.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
@@ -146,8 +146,10 @@ to the control plane. The middleware does the whole resource-side checklist:
146
146
  verify the token against your pinned PS, atomically reserve → commit →
147
147
  release per request, answer with `AAuth-Budget`, and refuse exhausted
148
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):
149
+ resource token with **one** consumption record — the presented token's
150
+ `{jti, consumed}` — so one agent never learns about a sibling's spending).
151
+ The cap is **per token**: a jti never draws on a sibling's grant; the
152
+ `(iss, sub, aud)` key is only the per-person ledger behind records and usage:
151
153
 
152
154
  ```python
153
155
  from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
@@ -172,6 +174,11 @@ production on [get4agent.com](https://get4agent.com).
172
174
 
173
175
  - `insufficient-budget` refusals carry **`required`** — the refused request's
174
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).
175
182
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
176
183
  the header, commit when the stream ends (set `request.state.budget_cost`
177
184
  mid-stream if you learn the actual), and the agent recovers the exact cost
@@ -116,8 +116,10 @@ to the control plane. The middleware does the whole resource-side checklist:
116
116
  verify the token against your pinned PS, atomically reserve → commit →
117
117
  release per request, answer with `AAuth-Budget`, and refuse exhausted
118
118
  envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
119
- resource token with the agent's own consumption records — scoped to its key,
120
- so one agent never learns about a sibling's spending):
119
+ resource token with **one** consumption record — the presented token's
120
+ `{jti, consumed}` — so one agent never learns about a sibling's spending).
121
+ The cap is **per token**: a jti never draws on a sibling's grant; the
122
+ `(iss, sub, aud)` key is only the per-person ledger behind records and usage:
121
123
 
122
124
  ```python
123
125
  from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
@@ -142,6 +144,11 @@ production on [get4agent.com](https://get4agent.com).
142
144
 
143
145
  - `insufficient-budget` refusals carry **`required`** — the refused request's
144
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).
145
152
  - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
146
153
  the header, commit when the stream ends (set `request.state.budget_cost`
147
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.4.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"
@@ -5,11 +5,23 @@ The auth token carries a spending envelope::
5
5
  "budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
6
6
 
7
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.
8
+ cost atomically, serve, commit the actual cost, release the difference.
9
+
10
+ Two things are counted, against different keys (draft §Aggregation):
11
+
12
+ * the **cap** is per auth token — committed consumption plus outstanding
13
+ reservations against the presented ``jti`` never exceed *its* ``budget``;
14
+ no cross-token arithmetic, one token never draws on a sibling's grant;
15
+ * the **ledger** is per person — consumption is posted to ``(iss, sub, aud)``
16
+ for the consumption record and the usage counters. It is not a second
17
+ ceiling: a request that fits its token's budget is never refused because
18
+ of a per-person total.
19
+
20
+ (0.4 and earlier pooled a principal's live grants into one purse. That let a
21
+ jti spend past its own grant, so the figure recorded against it could exceed
22
+ what its issuer granted and the overflow — spent from a sibling's allocation —
23
+ was never attributed. Dropped in 0.5.0; the ``required`` member makes a
24
+ fragmented agent's re-authorization a calculation instead.)
13
25
 
14
26
  Everything here is framework-free; the FastAPI glue lives in
15
27
  :mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
@@ -42,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
42
54
 
43
55
 
44
56
  class UnitMismatch(ValueError):
45
- """A grant's unit/decimals differ from the pool's — one envelope, one unit."""
57
+ """A grant's unit/decimals differ from the ledger's — one envelope, one unit."""
46
58
 
47
59
 
48
60
  @dataclass(frozen=True)
@@ -80,8 +92,8 @@ class BudgetClaim:
80
92
 
81
93
  @dataclass(frozen=True)
82
94
  class Reservation:
83
- """An atomic hold on the pool for one in-flight request. Never revised —
84
- committed (with the actual cost) or released, exactly once."""
95
+ """An atomic hold on one token's budget for one in-flight request. Never
96
+ revised — committed (with the actual cost) or released, exactly once."""
85
97
 
86
98
  rid: int
87
99
  key: MeterKey
@@ -91,7 +103,8 @@ class Reservation:
91
103
 
92
104
  @dataclass(frozen=True)
93
105
  class InsufficientBudget:
94
- """Refusal: the request's maximum cost exceeds the pool's remaining balance.
106
+ """Refusal: the request's maximum cost exceeds the presented token's
107
+ remaining balance.
95
108
  ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
96
109
  (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
97
110
 
@@ -204,21 +217,25 @@ class InMemoryMeter:
204
217
  return pool
205
218
 
206
219
  @staticmethod
207
- def _remaining(pool: _Pool) -> int:
208
- live = sum(a for a, _ in pool.grants.values())
209
- spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
210
- held = sum(a for _, a, _ in pool.reservations.values())
211
- return max(0, live - spent - held)
220
+ def _remaining(pool: _Pool, jti: str) -> int:
221
+ """The presented token's balance: its grant minus what was committed
222
+ against it minus what is held for it. Sibling tokens of the same
223
+ person do not enter — the cap is per token (§Aggregation)."""
224
+ grant = pool.grants.get(jti)
225
+ if grant is None:
226
+ return 0
227
+ held = sum(a for j, a, _ in pool.reservations.values() if j == jti)
228
+ return max(0, grant[0] - pool.consumed.get(jti, 0) - held)
212
229
 
213
230
  # ── public interface (the BudgetMeter contract) ──────────────────────────
214
231
 
215
232
  async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
216
233
  exp: float, jkt: str = "") -> None:
217
- """Register a token's envelope in the principal's pool (idempotent per
218
- ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
234
+ """Register a token's envelope under the person's ledger key (idempotent
235
+ per ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
219
236
  recorded so consumption records can be scoped to the presenting agent
220
237
  (one agent must not learn about its siblings). Raises
221
- :class:`UnitMismatch` if the pool already runs in a different unit —
238
+ :class:`UnitMismatch` if the ledger already runs in a different unit —
222
239
  one envelope, one unit, no FX at the meter."""
223
240
  async with self._lock:
224
241
  now = time.monotonic()
@@ -229,7 +246,7 @@ class InMemoryMeter:
229
246
  key, _Pool(unit=claim.unit, decimals=claim.decimals))
230
247
  if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
231
248
  raise UnitMismatch(
232
- f"pool runs in {pool.unit}/{pool.decimals}, "
249
+ f"ledger runs in {pool.unit}/{pool.decimals}, "
233
250
  f"grant is {claim.unit}/{claim.decimals}")
234
251
  pool.last_activity = now
235
252
  if jkt:
@@ -244,7 +261,7 @@ class InMemoryMeter:
244
261
  pool = self._purge(key, now)
245
262
  if pool is None or jti not in pool.grants:
246
263
  return InsufficientBudget(remaining=0, exhausted=True)
247
- remaining = self._remaining(pool)
264
+ remaining = self._remaining(pool, jti)
248
265
  if max_cost > remaining:
249
266
  return InsufficientBudget(remaining=remaining,
250
267
  exhausted=remaining == 0)
@@ -255,7 +272,7 @@ class InMemoryMeter:
255
272
 
256
273
  async def commit(self, res: Reservation, actual: int) -> int:
257
274
  """Commit the actual cost (clamped to the reserved amount — reservations
258
- are never revised upward) and return the pool's remaining balance."""
275
+ are never revised upward) and return the token's remaining balance."""
259
276
  async with self._lock:
260
277
  now = time.monotonic()
261
278
  pool = self._purge(res.key, now)
@@ -267,7 +284,7 @@ class InMemoryMeter:
267
284
  pool.last_activity = now
268
285
  if cost > 0:
269
286
  self._record_usage(res.key, pool, res.jti, cost)
270
- return self._remaining(pool)
287
+ return self._remaining(pool, res.jti)
271
288
 
272
289
  async def release(self, res: Reservation) -> int:
273
290
  async with self._lock:
@@ -275,12 +292,14 @@ class InMemoryMeter:
275
292
  if pool is None:
276
293
  return 0
277
294
  pool.reservations.pop(res.rid, None)
278
- return self._remaining(pool)
295
+ return self._remaining(pool, res.jti)
279
296
 
280
- async def remaining(self, key: MeterKey) -> int:
297
+ async def remaining(self, key: MeterKey, jti: str) -> int:
298
+ """The presented token's remaining balance (0 for an unknown or
299
+ expired ``jti``)."""
281
300
  async with self._lock:
282
301
  pool = self._purge(key, time.monotonic())
283
- return 0 if pool is None else self._remaining(pool)
302
+ return 0 if pool is None else self._remaining(pool, jti)
284
303
 
285
304
  def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
286
305
  amount: int) -> None:
@@ -322,11 +341,25 @@ class InMemoryMeter:
322
341
  or ``None`` before the first commit."""
323
342
  return self._metering_unit
324
343
 
344
+ async def consumed_record(self, key: MeterKey, jti: str) -> dict[str, Any] | None:
345
+ """The consumption record for the resource token's ``budget_consumed``
346
+ claim (draft §The Consumption Record): ``{"jti", "consumed"}`` for the
347
+ PRESENTED token — its total metered so far — or ``None`` when nothing
348
+ was metered against it. One record, the presented token's; the spend
349
+ under a person's other tokens is the usage endpoint's to report."""
350
+ async with self._lock:
351
+ pool = self._purge(key, time.monotonic())
352
+ if pool is None:
353
+ return None
354
+ total = pool.consumed.get(jti, 0)
355
+ return {"jti": jti, "consumed": total} if total > 0 else None
356
+
325
357
  async def consumed_records(self, key: MeterKey,
326
358
  jkt: str | None = None) -> list[dict[str, Any]]:
327
- """Per-token consumption for the resource token's ``budget_consumed``
328
- claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
329
- PS deduplicates by ``jti``, so reporting the same record twice is safe.
359
+ """Audit view: per-token consumption under this ledger key,
360
+ ``[{"jti": ..., "consumed": ...}, ...]``. Not what goes on the wire —
361
+ the resource token carries :meth:`consumed_record` — but the figures a
362
+ PS-side reconciliation or an operator wants to see.
330
363
 
331
364
  When ``jkt`` is given, records are scoped to tokens bound to that key:
332
365
  the agent carrying the resource token sees only its OWN spending, never
@@ -122,7 +122,7 @@ RequiredSignatureDep = Depends(require_signature)
122
122
 
123
123
  PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
124
124
  ResourceTokenProvider = Callable[
125
- [MeterKey, "list[dict[str, Any]]"], "str | None | Awaitable[str | None]"
125
+ [MeterKey, "dict[str, Any] | None"], "str | None | Awaitable[str | None]"
126
126
  ]
127
127
 
128
128
 
@@ -158,9 +158,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
158
158
  ``require=True`` refuses them with 401 + ``AAuth-Requirement``.
159
159
  - Error responses (4xx/5xx) release the reservation — nothing was served,
160
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.
161
+ - ``resource_token_provider(key, record)`` (optional) mints the resource
162
+ token embedded in budget-refusal responses so the agent can carry
163
+ ``budget_consumed`` back to its PS for re-authorization. ``record`` is
164
+ the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
165
+ Record — one record, two members) or ``None`` when nothing was metered
166
+ against it yet.
164
167
  """
165
168
 
166
169
  def __init__(
@@ -188,6 +191,13 @@ class BudgetMiddleware(BaseHTTPMiddleware):
188
191
  return await call_next(request)
189
192
 
190
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)
191
201
  envelope: BudgetClaim | None = None
192
202
  if sig is not None:
193
203
  try:
@@ -223,7 +233,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
223
233
  reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
224
234
  return await self._refusal_with_token(
225
235
  reason=reason, envelope=envelope, remaining=outcome.remaining,
226
- key=key, jkt=sig.keyid,
236
+ key=key, jti=jti,
227
237
  # `required` rides only on insufficient-budget: what THIS
228
238
  # request needed, so the agent can lower its bound and retry.
229
239
  required=int(max_cost) if reason == "insufficient-budget" else None,
@@ -247,7 +257,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
247
257
  # when `cost` is omitted — with `remaining` already net of the
248
258
  # hold, and commit when the stream completes. The agent recovers
249
259
  # the exact figure from the next response's `remaining`.
250
- remaining = await self._meter.remaining(key)
260
+ remaining = await self._meter.remaining(key, jti)
251
261
  response.headers["AAuth-Budget"] = build_aauth_budget_header(
252
262
  remaining=remaining, reserved=int(max_cost),
253
263
  unit=envelope.unit, decimals=envelope.decimals,
@@ -304,21 +314,55 @@ class BudgetMiddleware(BaseHTTPMiddleware):
304
314
  result = None
305
315
  if "signature" in request.headers:
306
316
  result = await self._verifier.verify(
307
- request.method, _public_url(request), dict(request.headers)
317
+ request.method, _public_url(request), dict(request.headers),
318
+ allow_expired_auth_token=True,
308
319
  )
309
- 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)
310
326
  return result
311
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
+
312
353
  async def _refusal_with_token(
313
354
  self, *, reason: str, envelope: BudgetClaim,
314
- remaining: int, key: MeterKey, jkt: str | None = None,
355
+ remaining: int, key: MeterKey, jti: str,
315
356
  required: int | None = None,
316
357
  ) -> Response:
317
358
  token: str | None = None
318
359
  if self._resource_token is not None:
319
360
  try:
320
- records = await self._meter.consumed_records(key, jkt=jkt)
321
- token = await _maybe_await(self._resource_token(key, records))
361
+ # One record, the presented token's (§The Consumption Record):
362
+ # what THIS jti has cost so far. Siblings' spend never rides
363
+ # home with this agent — the usage endpoint reports that.
364
+ record = await self._meter.consumed_record(key, jti)
365
+ token = await _maybe_await(self._resource_token(key, record))
322
366
  except Exception: # noqa: BLE001 — refusal must not fail on the extras
323
367
  logger.warning("resource_token_provider failed", exc_info=True)
324
368
  return self._refusal(reason=reason, envelope=envelope,
@@ -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
  )
@@ -105,19 +105,29 @@ async def test_meter_reserve_commit_release_math() -> None:
105
105
  await meter.observe_grant(KEY, "jti-1", _claim(1000), time.time() + 60)
106
106
  res = await meter.reserve(KEY, "jti-1", 300)
107
107
  assert isinstance(res, Reservation)
108
- assert await meter.remaining(KEY) == 700 # 300 held
108
+ assert await meter.remaining(KEY, "jti-1") == 700 # 300 held
109
109
  assert await meter.commit(res, 120) == 880 # difference released
110
110
  assert await meter.consumed_records(KEY) == [{"jti": "jti-1", "consumed": 120}]
111
111
 
112
112
 
113
- async def test_meter_pools_across_live_tokens() -> None:
114
- """The draft's (iss, sub, aud) aggregation: two live envelopes pool."""
113
+ async def test_meter_caps_each_token_at_its_own_budget() -> None:
114
+ """§Aggregation: the cap is per auth token. Two live envelopes of one
115
+ person do NOT pool — a jti never draws on a sibling's grant, so the
116
+ figure recorded against it can never exceed what its issuer granted."""
115
117
  meter = InMemoryMeter()
116
118
  await meter.observe_grant(KEY, "a", _claim(300), time.time() + 60)
117
119
  await meter.observe_grant(KEY, "b", _claim(300), time.time() + 60)
118
- res = await meter.reserve(KEY, "a", 500) # more than either grant alone
120
+ refusal = await meter.reserve(KEY, "a", 500) # would have fit a pooled purse
121
+ assert isinstance(refusal, InsufficientBudget)
122
+ assert refusal.remaining == 300 and not refusal.exhausted
123
+ res = await meter.reserve(KEY, "a", 300)
119
124
  assert isinstance(res, Reservation)
120
- assert await meter.commit(res, 500) == 100
125
+ assert await meter.commit(res, 300) == 0 # a is spent…
126
+ assert await meter.remaining(KEY, "b") == 300 # …b is untouched
127
+ assert await meter.consumed_record(KEY, "a") == {"jti": "a", "consumed": 300}
128
+ assert await meter.consumed_record(KEY, "b") is None
129
+ # The ledger still aggregates per person for records/usage.
130
+ assert await meter.consumed_records(KEY) == [{"jti": "a", "consumed": 300}]
121
131
 
122
132
 
123
133
  async def test_meter_insufficient_vs_exhausted() -> None:
@@ -163,7 +173,7 @@ async def test_meter_expired_reservation_counts_as_consumed() -> None:
163
173
  res = await meter.reserve(KEY, "jti-1", 400)
164
174
  assert isinstance(res, Reservation)
165
175
  # Handler "crashed": never commits. The next touch resolves it as consumed.
166
- assert await meter.remaining(KEY) == 600
176
+ assert await meter.remaining(KEY, "jti-1") == 600
167
177
  assert await meter.consumed_records(KEY) == [{"jti": "jti-1", "consumed": 400}]
168
178
 
169
179
 
@@ -176,9 +186,9 @@ async def test_meter_commit_clamps_to_reservation() -> None:
176
186
 
177
187
 
178
188
  async def test_consumed_records_scoped_to_presenting_jkt() -> None:
179
- """Two agents of one principal share the (iss, sub, aud) pool, but each
180
- sees only ITS OWN consumption records — never its siblings' (privacy +
181
- no extra figures to infer the ceiling from)."""
189
+ """Two agents of one person post to the same (iss, sub, aud) ledger, but
190
+ each sees only ITS OWN consumption records — never its siblings' (privacy
191
+ + no extra figures to infer the ceiling from)."""
182
192
  meter = InMemoryMeter()
183
193
  now = time.time()
184
194
  await meter.observe_grant(KEY, "jti-a", _claim(500), now + 60, jkt="jkt-agent-A")
@@ -194,15 +204,16 @@ async def test_consumed_records_scoped_to_presenting_jkt() -> None:
194
204
  assert await meter.consumed_records(KEY, jkt="jkt-agent-B") == [
195
205
  {"jti": "jti-b", "consumed": 250}
196
206
  ]
197
- # Unscoped (PS-side / audit view) still returns the whole pool.
207
+ # Unscoped (PS-side / audit view) still returns the whole ledger.
198
208
  assert len(await meter.consumed_records(KEY)) == 2
199
209
 
200
210
 
201
211
  async def test_refusal_records_scoped_to_presenter(
202
212
  monkeypatch: pytest.MonkeyPatch,
203
213
  ) -> None:
204
- """The resource token embedded in a refusal carries only the presenting
205
- agent's records: sibling B's spend must not ride home with agent A."""
214
+ """The resource token embedded in a refusal carries ONE record — the
215
+ presented token's (§The Consumption Record): sibling A's spend must not
216
+ ride home with agent B, and B's other tokens don't either."""
206
217
  ps_priv, ps_jwk = _ps_pair()
207
218
  agent_a = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
208
219
  agent_b = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
@@ -216,15 +227,16 @@ async def test_refusal_records_scoped_to_presenter(
216
227
 
217
228
  app = _app(_verifier(ps_jwk, monkeypatch), resource_token_provider=provider)
218
229
 
219
- # A spends 300 (price of /v1/search), B spends 300 — pool now at 200.
230
+ # A spends 300 (price of /v1/search) of its 400; B spends 300 of its 400.
220
231
  assert (await _post(app, "/v1/search",
221
232
  _signed_headers(agent_a, token_a, "/v1/search"))).status_code == 200
222
233
  assert (await _post(app, "/v1/search",
223
234
  _signed_headers(agent_b, token_b, "/v1/search"))).status_code == 200
224
- # B asks again: 300 > 200 remaining → refusal with records — B's only.
235
+ # B asks again: 300 > 100 remaining on at-B → refusal carrying at-B's record only.
225
236
  r = await _post(app, "/v1/search", _signed_headers(agent_b, token_b, "/v1/search"))
226
237
  assert r.status_code == 401
227
- assert provider_records == [[{"jti": "at-B", "consumed": 300}]]
238
+ assert provider_records == [{"jti": "at-B", "consumed": 300}]
239
+ assert r.headers["AAuth-Budget"] == 'remaining=100, required=300, unit="KZT", decimals=2'
228
240
 
229
241
 
230
242
  async def test_meter_concurrent_reserves_never_oversell() -> None:
@@ -254,11 +266,13 @@ def _ps_pair() -> tuple[Ed25519PrivateKey, dict[str, Any]]:
254
266
 
255
267
 
256
268
  def _auth_token(ps_priv: Ed25519PrivateKey, agent: EgressSigner, *,
257
- 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."""
258
272
  from regent_httpsig.verify import _register_fully_specified_algs
259
273
 
260
274
  _register_fully_specified_algs() # PyJWT knows "Ed25519" only after this
261
- now = int(time.time())
275
+ now = int(time.time()) - age
262
276
  return pyjwt.encode(
263
277
  {
264
278
  "iss": PS_ISS, "sub": "owner-1", "aud": RESOURCE, "jti": jti,
@@ -380,7 +394,7 @@ async def test_middleware_refuses_when_insufficient(
380
394
  assert (r2.headers["AAuth-Budget"] ==
381
395
  'remaining=200, required=300, unit="KZT", decimals=2')
382
396
  assert provider_calls and provider_calls[0][0] == (PS_ISS, "owner-1", RESOURCE)
383
- assert provider_calls[0][1] == [{"jti": "at-1", "consumed": 300}]
397
+ assert provider_calls[0][1] == {"jti": "at-1", "consumed": 300} # one record (#120)
384
398
 
385
399
 
386
400
  async def test_middleware_actual_cost_from_handler(
@@ -487,3 +501,70 @@ async def test_streaming_cost_omitted_reserved_math(
487
501
  next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
488
502
  recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
489
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
@@ -142,7 +142,7 @@ async def test_expired_reservation_reaches_usage_counters() -> None:
142
142
  await meter.observe_grant(KEY, "jti-1", CLAIM, time.time() + 3600, jkt=JKT_A)
143
143
  await meter.reserve(KEY, "jti-1", 700)
144
144
  await asyncio.sleep(0.02)
145
- await meter.remaining(KEY) # triggers the purge
145
+ await meter.remaining(KEY, "jti-1") # triggers the purge
146
146
  assert (await meter.usage_keys(KEY[0], [JKT_A]))[JKT_A] == 700
147
147
 
148
148
 
@@ -68,20 +68,15 @@
68
68
  ]
69
69
  },
70
70
  "consumption_records": {
71
- "budget_consumed": [
72
- {
73
- "jti": "at-2",
74
- "consumed": 431200
75
- },
76
- {
77
- "jti": "at-1",
78
- "consumed": 221200
79
- }
80
- ],
71
+ "budget_consumed": {
72
+ "jti": "at-2",
73
+ "consumed": 431200
74
+ },
81
75
  "notes": [
82
- "two members and no more \u2014 the PS joins the rest from its ledger",
83
- "records are scoped to the presenting agent's key (jkt): one agent never learns a sibling's spending",
84
- "most recent first; SHOULD NOT exceed 20 records"
76
+ "ONE record \u2014 the presented token's \u2014 two members and no more; the issuer joins the rest from its ledger (#120)",
77
+ "consumed is that token's total as of the resource token's iat",
78
+ "final vs snapshot is decided by the issuer: resource token iat >= auth token exp is the final figure and releases the remainder; a record on a live token releases nothing",
79
+ "the spend under a person's other tokens is the usage endpoint's to report \u2014 never carried by a sibling"
85
80
  ]
86
81
  },
87
82
  "signed_usage_response": {
@@ -87,15 +87,16 @@ def budget_claim() -> dict:
87
87
 
88
88
  def consumption_records() -> dict:
89
89
  return {
90
- "budget_consumed": [
91
- {"jti": "at-2", "consumed": 431200},
92
- {"jti": "at-1", "consumed": 221200},
93
- ],
90
+ "budget_consumed": {"jti": "at-2", "consumed": 431200},
94
91
  "notes": [
95
- "two members and no more — the PS joins the rest from its ledger",
96
- "records are scoped to the presenting agent's key (jkt): one "
97
- "agent never learns a sibling's spending",
98
- "most recent first; SHOULD NOT exceed 20 records",
92
+ "ONE record — the presented token's — two members and no more; "
93
+ "the issuer joins the rest from its ledger (#120)",
94
+ "consumed is that token's total as of the resource token's iat",
95
+ "final vs snapshot is decided by the issuer: resource token iat >= "
96
+ "auth token exp is the final figure and releases the remainder; "
97
+ "a record on a live token releases nothing",
98
+ "the spend under a person's other tokens is the usage endpoint's "
99
+ "to report — never carried by a sibling",
99
100
  ],
100
101
  }
101
102
 
File without changes