regent-httpsig 0.3.0__py3-none-any.whl → 0.5.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.
@@ -14,6 +14,14 @@ from regent_httpsig.budget import (
14
14
  from regent_httpsig.config import HttpsigConfig
15
15
  from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
16
16
  from regent_httpsig.netguard import NotPublicURL, assert_public_url
17
+ from regent_httpsig.usage import (
18
+ ResponseSigner,
19
+ UsageQueryError,
20
+ build_usage_response,
21
+ make_usage_endpoint,
22
+ parse_usage_request,
23
+ validate_budget_grant,
24
+ )
17
25
  from regent_httpsig.sfv import (
18
26
  build_aauth_budget_header,
19
27
  build_aauth_requirement,
@@ -40,6 +48,12 @@ __all__ = [
40
48
  "__version__",
41
49
  "assert_public_url",
42
50
  "b64url",
51
+ "ResponseSigner",
52
+ "UsageQueryError",
53
+ "build_usage_response",
54
+ "make_usage_endpoint",
55
+ "parse_usage_request",
56
+ "validate_budget_grant",
43
57
  "build_aauth_budget_header",
44
58
  "build_aauth_requirement",
45
59
  "generate_seed",
regent_httpsig/budget.py CHANGED
@@ -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``).
@@ -21,6 +33,7 @@ import asyncio
21
33
  import itertools
22
34
  import time
23
35
  from collections.abc import Mapping
36
+ from datetime import UTC, datetime, timedelta
24
37
  from dataclasses import dataclass, field
25
38
  from typing import Any
26
39
 
@@ -41,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
41
54
 
42
55
 
43
56
  class UnitMismatch(ValueError):
44
- """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."""
45
58
 
46
59
 
47
60
  @dataclass(frozen=True)
@@ -79,8 +92,8 @@ class BudgetClaim:
79
92
 
80
93
  @dataclass(frozen=True)
81
94
  class Reservation:
82
- """An atomic hold on the pool for one in-flight request. Never revised —
83
- committed (with the actual cost) or released, exactly once."""
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."""
84
97
 
85
98
  rid: int
86
99
  key: MeterKey
@@ -90,7 +103,8 @@ class Reservation:
90
103
 
91
104
  @dataclass(frozen=True)
92
105
  class InsufficientBudget:
93
- """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.
94
108
  ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
95
109
  (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
96
110
 
@@ -109,6 +123,48 @@ class _Pool:
109
123
  last_activity: float = 0.0
110
124
 
111
125
 
126
+ @dataclass
127
+ class _ScopeCounters:
128
+ """Calendar counters for one usage scope (draft §calendar-counters): running
129
+ integers bucketed on UTC boundaries — no per-record history is kept."""
130
+
131
+ all_time: int = 0
132
+ day: int = 0
133
+ week: int = 0
134
+ month: int = 0
135
+ year: int = 0
136
+ day_start: float = 0.0
137
+ week_start: float = 0.0
138
+ month_start: float = 0.0
139
+ year_start: float = 0.0
140
+
141
+ def add(self, amount: int, wall: float) -> None:
142
+ self._roll(wall)
143
+ self.all_time += amount
144
+ self.day += amount
145
+ self.week += amount
146
+ self.month += amount
147
+ self.year += amount
148
+
149
+ def snapshot(self, wall: float) -> dict[str, int]:
150
+ self._roll(wall)
151
+ return {"day": self.day, "week": self.week, "month": self.month,
152
+ "year": self.year, "all_time": self.all_time}
153
+
154
+ def _roll(self, wall: float) -> None:
155
+ dt = datetime.fromtimestamp(wall, tz=UTC)
156
+ day = dt.replace(hour=0, minute=0, second=0, microsecond=0)
157
+ week = day - timedelta(days=dt.weekday()) # Monday 00:00 UTC (ISO 8601)
158
+ month = day.replace(day=1)
159
+ year = month.replace(month=1)
160
+ for name, start in (("day", day), ("week", week),
161
+ ("month", month), ("year", year)):
162
+ ts = start.timestamp()
163
+ if getattr(self, f"{name}_start") < ts:
164
+ setattr(self, name, 0)
165
+ setattr(self, f"{name}_start", ts)
166
+
167
+
112
168
  class InMemoryMeter:
113
169
  """Single-process meter (asyncio-safe). Right for a single-instance service;
114
170
  multi-replica deployments need a shared backend behind the same interface.
@@ -119,12 +175,23 @@ class InMemoryMeter:
119
175
  """
120
176
 
121
177
  def __init__(self, *, reservation_ttl: float = 120.0,
122
- retention_seconds: float = 7200.0) -> None:
178
+ retention_seconds: float = 7200.0,
179
+ usage_key_retention: float = 86400.0) -> None:
123
180
  self._pools: dict[MeterKey, _Pool] = {}
124
181
  self._lock = asyncio.Lock()
125
182
  self._rids = itertools.count(1)
126
183
  self._reservation_ttl = reservation_ttl
127
184
  self._retention = retention_seconds
185
+ # Usage counters (draft §usage-counters) — wall-clock, keyed by the
186
+ # issuing PS so the endpoint only answers the party whose tokens we
187
+ # accepted. Scope counters never expire (all_time reaches as far back
188
+ # as the resource retains); per-key figures are pruned on IDLE time —
189
+ # "SHOULD retain … at least 24 hours after that key's last metered
190
+ # request" — so a key in continuous use is never pruned.
191
+ self._usage_key_retention = usage_key_retention
192
+ 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)
194
+ self._metering_unit: tuple[str, int] | None = None
128
195
 
129
196
  # ── internals (call under lock) ──────────────────────────────────────────
130
197
 
@@ -137,6 +204,7 @@ class InMemoryMeter:
137
204
  if deadline <= now:
138
205
  pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
139
206
  del pool.reservations[rid]
207
+ self._record_usage(key, pool, jti, amount)
140
208
  # Expired grants leave the pool; their consumption records remain for
141
209
  # budget_consumed reporting until the retention window passes.
142
210
  for jti, (_, exp) in list(pool.grants.items()):
@@ -149,21 +217,25 @@ class InMemoryMeter:
149
217
  return pool
150
218
 
151
219
  @staticmethod
152
- def _remaining(pool: _Pool) -> int:
153
- live = sum(a for a, _ in pool.grants.values())
154
- spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
155
- held = sum(a for _, a, _ in pool.reservations.values())
156
- return max(0, live - spent - held)
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)
157
229
 
158
230
  # ── public interface (the BudgetMeter contract) ──────────────────────────
159
231
 
160
232
  async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
161
233
  exp: float, jkt: str = "") -> None:
162
- """Register a token's envelope in the principal's pool (idempotent per
163
- ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
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 —
164
236
  recorded so consumption records can be scoped to the presenting agent
165
237
  (one agent must not learn about its siblings). Raises
166
- :class:`UnitMismatch` if the pool already runs in a different unit —
238
+ :class:`UnitMismatch` if the ledger already runs in a different unit —
167
239
  one envelope, one unit, no FX at the meter."""
168
240
  async with self._lock:
169
241
  now = time.monotonic()
@@ -174,7 +246,7 @@ class InMemoryMeter:
174
246
  key, _Pool(unit=claim.unit, decimals=claim.decimals))
175
247
  if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
176
248
  raise UnitMismatch(
177
- f"pool runs in {pool.unit}/{pool.decimals}, "
249
+ f"ledger runs in {pool.unit}/{pool.decimals}, "
178
250
  f"grant is {claim.unit}/{claim.decimals}")
179
251
  pool.last_activity = now
180
252
  if jkt:
@@ -189,7 +261,7 @@ class InMemoryMeter:
189
261
  pool = self._purge(key, now)
190
262
  if pool is None or jti not in pool.grants:
191
263
  return InsufficientBudget(remaining=0, exhausted=True)
192
- remaining = self._remaining(pool)
264
+ remaining = self._remaining(pool, jti)
193
265
  if max_cost > remaining:
194
266
  return InsufficientBudget(remaining=remaining,
195
267
  exhausted=remaining == 0)
@@ -200,7 +272,7 @@ class InMemoryMeter:
200
272
 
201
273
  async def commit(self, res: Reservation, actual: int) -> int:
202
274
  """Commit the actual cost (clamped to the reserved amount — reservations
203
- are never revised upward) and return the pool's remaining balance."""
275
+ are never revised upward) and return the token's remaining balance."""
204
276
  async with self._lock:
205
277
  now = time.monotonic()
206
278
  pool = self._purge(res.key, now)
@@ -210,7 +282,9 @@ class InMemoryMeter:
210
282
  cost = min(max(actual, 0), held[1] if held else res.amount)
211
283
  pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
212
284
  pool.last_activity = now
213
- return self._remaining(pool)
285
+ if cost > 0:
286
+ self._record_usage(res.key, pool, res.jti, cost)
287
+ return self._remaining(pool, res.jti)
214
288
 
215
289
  async def release(self, res: Reservation) -> int:
216
290
  async with self._lock:
@@ -218,18 +292,74 @@ class InMemoryMeter:
218
292
  if pool is None:
219
293
  return 0
220
294
  pool.reservations.pop(res.rid, None)
221
- return self._remaining(pool)
295
+ return self._remaining(pool, res.jti)
222
296
 
223
- 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``)."""
224
300
  async with self._lock:
225
301
  pool = self._purge(key, time.monotonic())
226
- return 0 if pool is None else self._remaining(pool)
302
+ return 0 if pool is None else self._remaining(pool, jti)
303
+
304
+ def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
305
+ amount: int) -> None:
306
+ """Post a committed cost to the usage counters (call under lock).
307
+ Wall-clock, because calendar boundaries are UTC by definition."""
308
+ if amount <= 0:
309
+ return
310
+ wall = time.time()
311
+ iss, sub, _aud = key
312
+ self._metering_unit = self._metering_unit or (pool.unit, pool.decimals)
313
+ self._scope_usage.setdefault((iss, sub), _ScopeCounters()).add(amount, wall)
314
+ jkt = pool.jkt_of.get(jti)
315
+ if jkt:
316
+ total, _ = self._key_usage.get((iss, jkt), (0, 0.0))
317
+ self._key_usage[(iss, jkt)] = (total + amount, wall)
318
+
319
+ async def usage_scope(self, iss: str, sub: str) -> dict[str, int] | None:
320
+ """Calendar counters for a ``sub`` scope query, or ``None`` when the
321
+ resource holds no figure — the endpoint then omits ``usage``, keeping
322
+ "never seen" indistinguishable from "nothing consumed"."""
323
+ async with self._lock:
324
+ counters = self._scope_usage.get((iss, sub))
325
+ return None if counters is None else counters.snapshot(time.time())
326
+
327
+ async def usage_keys(self, iss: str, jkts: list[str]) -> dict[str, int]:
328
+ """Per-key totals for a ``jkts`` query. Unrecognized or pruned keys are
329
+ OMITTED, never reported as zero — absence means "cannot answer", a
330
+ present zero would be a wrong answer to an allocation decision."""
331
+ async with self._lock:
332
+ wall = time.time()
333
+ for pair, (_, last) in list(self._key_usage.items()):
334
+ if wall - last > self._usage_key_retention:
335
+ del self._key_usage[pair]
336
+ return {jkt: self._key_usage[(iss, jkt)][0]
337
+ for jkt in jkts if (iss, jkt) in self._key_usage}
338
+
339
+ def metering_unit(self) -> tuple[str, int] | None:
340
+ """The one unit every usage figure is denominated in (draft §one-unit),
341
+ or ``None`` before the first commit."""
342
+ return self._metering_unit
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
227
356
 
228
357
  async def consumed_records(self, key: MeterKey,
229
358
  jkt: str | None = None) -> list[dict[str, Any]]:
230
- """Per-token consumption for the resource token's ``budget_consumed``
231
- claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
232
- PS deduplicates by ``jti``, so reporting the same record twice is safe.
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.
233
363
 
234
364
  When ``jkt`` is given, records are scoped to tokens bound to that key:
235
365
  the agent carrying the resource token sees only its OWN spending, never
regent_httpsig/fastapi.py CHANGED
@@ -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__(
@@ -223,7 +226,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
223
226
  reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
224
227
  return await self._refusal_with_token(
225
228
  reason=reason, envelope=envelope, remaining=outcome.remaining,
226
- key=key, jkt=sig.keyid,
229
+ key=key, jti=jti,
230
+ # `required` rides only on insufficient-budget: what THIS
231
+ # request needed, so the agent can lower its bound and retry.
232
+ required=int(max_cost) if reason == "insufficient-budget" else None,
227
233
  )
228
234
 
229
235
  reservation: Reservation = outcome
@@ -237,6 +243,21 @@ class BudgetMiddleware(BaseHTTPMiddleware):
237
243
  # Nothing was served — the envelope is not charged for errors.
238
244
  remaining = await self._meter.release(reservation)
239
245
  cost = 0
246
+ elif self._is_streamed(request, response):
247
+ # Cost-omitted mode (draft §cost-omitted): a streamed response's
248
+ # actual cost is known only when the stream ends, and this runtime
249
+ # sends no trailers. We state what we HOLD — `reserved`, REQUIRED
250
+ # when `cost` is omitted — with `remaining` already net of the
251
+ # hold, and commit when the stream completes. The agent recovers
252
+ # the exact figure from the next response's `remaining`.
253
+ remaining = await self._meter.remaining(key, jti)
254
+ response.headers["AAuth-Budget"] = build_aauth_budget_header(
255
+ remaining=remaining, reserved=int(max_cost),
256
+ unit=envelope.unit, decimals=envelope.decimals,
257
+ )
258
+ self._commit_after_stream(request, response, reservation,
259
+ int(max_cost))
260
+ return response
240
261
  else:
241
262
  actual = getattr(request.state, "budget_cost", None)
242
263
  cost = int(actual) if actual is not None else int(max_cost)
@@ -247,6 +268,36 @@ class BudgetMiddleware(BaseHTTPMiddleware):
247
268
  )
248
269
  return response
249
270
 
271
+ @staticmethod
272
+ def _is_streamed(request: Request, response: Response) -> bool:
273
+ """A handler opts in with ``request.state.budget_streaming = True``;
274
+ SSE responses are recognized on their own."""
275
+ if getattr(request.state, "budget_streaming", False):
276
+ return True
277
+ ctype = response.headers.get("content-type", "")
278
+ return ctype.startswith("text/event-stream")
279
+
280
+ def _commit_after_stream(self, request: Request, response: Response,
281
+ reservation: Reservation, max_cost: int) -> None:
282
+ """Wrap the body iterator: commit when the stream ends (the handler may
283
+ set ``request.state.budget_cost`` while streaming), release the unspent
284
+ remainder; a broken stream commits the full hold — conservative, per
285
+ the reservation-timeout rule."""
286
+ inner = response.body_iterator # type: ignore[attr-defined]
287
+
288
+ async def metered() -> Any:
289
+ ok = False
290
+ try:
291
+ async for chunk in inner:
292
+ yield chunk
293
+ ok = True
294
+ finally:
295
+ actual = getattr(request.state, "budget_cost", None)
296
+ cost = int(actual) if (ok and actual is not None) else max_cost
297
+ await self._meter.commit(reservation, cost)
298
+
299
+ response.body_iterator = metered() # type: ignore[attr-defined]
300
+
250
301
  # ── helpers ──────────────────────────────────────────────────────────────
251
302
 
252
303
  async def _verified(self, request: Request) -> VerifiedSignature | None:
@@ -263,22 +314,27 @@ class BudgetMiddleware(BaseHTTPMiddleware):
263
314
 
264
315
  async def _refusal_with_token(
265
316
  self, *, reason: str, envelope: BudgetClaim,
266
- remaining: int, key: MeterKey, jkt: str | None = None,
317
+ remaining: int, key: MeterKey, jti: str,
318
+ required: int | None = None,
267
319
  ) -> Response:
268
320
  token: str | None = None
269
321
  if self._resource_token is not None:
270
322
  try:
271
- records = await self._meter.consumed_records(key, jkt=jkt)
272
- token = await _maybe_await(self._resource_token(key, records))
323
+ # One record, the presented token's (§The Consumption Record):
324
+ # what THIS jti has cost so far. Siblings' spend never rides
325
+ # home with this agent — the usage endpoint reports that.
326
+ record = await self._meter.consumed_record(key, jti)
327
+ token = await _maybe_await(self._resource_token(key, record))
273
328
  except Exception: # noqa: BLE001 — refusal must not fail on the extras
274
329
  logger.warning("resource_token_provider failed", exc_info=True)
275
330
  return self._refusal(reason=reason, envelope=envelope,
276
- remaining=remaining, resource_token=token)
331
+ remaining=remaining, resource_token=token,
332
+ required=required)
277
333
 
278
334
  def _refusal(
279
335
  self, *, reason: str | None, envelope: BudgetClaim | None,
280
336
  remaining: int | None, key: MeterKey | None = None,
281
- resource_token: str | None = None,
337
+ resource_token: str | None = None, required: int | None = None,
282
338
  ) -> Response:
283
339
  headers = {
284
340
  "AAuth-Requirement": build_aauth_requirement(
@@ -288,7 +344,8 @@ class BudgetMiddleware(BaseHTTPMiddleware):
288
344
  }
289
345
  if remaining is not None and envelope is not None:
290
346
  headers["AAuth-Budget"] = build_aauth_budget_header(
291
- remaining=remaining, unit=envelope.unit, decimals=envelope.decimals
347
+ remaining=remaining, required=required,
348
+ unit=envelope.unit, decimals=envelope.decimals,
292
349
  )
293
350
  code = "AUTH_TOKEN_REQUIRED" if reason is None else reason.upper().replace("-", "_")
294
351
  return JSONResponse(
regent_httpsig/sfv.py CHANGED
@@ -151,11 +151,15 @@ def build_aauth_budget_header(
151
151
  remaining: int,
152
152
  cost: int | None = None,
153
153
  reserved: int | None = None,
154
+ required: int | None = None,
154
155
  unit: str | None = None,
155
156
  decimals: int | None = None,
156
157
  ) -> str:
157
158
  """Serialize the ``AAuth-Budget`` response header. ``remaining`` is the only
158
- REQUIRED member; ``unit``/``decimals`` must travel together or not at all."""
159
+ REQUIRED member; ``unit``/``decimals`` must travel together or not at all.
160
+ ``required`` is the maximum cost of a request refused ``insufficient-budget``
161
+ — sent only with that refusal, so the agent's retry is a calculation
162
+ (lower the bound to fit ``remaining``) rather than a search."""
159
163
  if (unit is None) != (decimals is None):
160
164
  raise ValueError("unit and decimals must be provided together")
161
165
  members: list[str] = []
@@ -164,6 +168,8 @@ def build_aauth_budget_header(
164
168
  members.append(f"remaining={remaining}")
165
169
  if reserved is not None:
166
170
  members.append(f"reserved={reserved}")
171
+ if required is not None:
172
+ members.append(f"required={required}")
167
173
  if unit is not None and decimals is not None:
168
174
  members.append(f"unit={_sf_string(unit)}")
169
175
  members.append(f"decimals={decimals}")
@@ -0,0 +1,223 @@
1
+ """AAuth Budgets usage endpoint (draft-hardt-aauth-budgets, §Usage Counters).
2
+
3
+ Consumption records reach the PS only when an agent carries a resource token
4
+ home; this endpoint removes the agent from that loop — the PS queries the
5
+ resource directly, on a channel the agent is never on.
6
+
7
+ The library owns the contract: query validation, the response shape, and the
8
+ RECOMMENDED response signature. Caller authentication is the application's
9
+ (``authenticate_ps``) — it already knows which person servers it trusts and
10
+ how it verifies their signatures, exactly as it does for auth tokens.
11
+
12
+ Draft rules encoded here, each load-bearing:
13
+
14
+ - Exactly one scope key (``sub`` | ``tenant`` | ``mission_s256``) or ``jkts``;
15
+ both MAY appear; two scope keys or neither-nor-jkts is an error.
16
+ - An unrecognized scope value returns ``200`` with ``usage`` omitted — "never
17
+ seen" and "nothing consumed" are deliberately indistinguishable, so a query
18
+ cannot discover whether a person holds an account.
19
+ - An unrecognized or pruned thumbprint is OMITTED from ``jkts``, never zero:
20
+ absence means "cannot answer"; a present zero would be a wrong answer to an
21
+ allocation decision.
22
+ - One unit per response, named once at the top level.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import hashlib
29
+ import json
30
+ import time
31
+ from collections.abc import Awaitable, Callable
32
+ from typing import Any
33
+
34
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
35
+
36
+ from regent_httpsig.jwk import b64url_decode, jwk_thumbprint
37
+
38
+ __all__ = ["UsageQueryError", "ResponseSigner", "build_usage_response",
39
+ "parse_usage_request", "make_usage_endpoint",
40
+ "validate_budget_grant"]
41
+
42
+ _SCOPE_KEYS = ("sub", "tenant", "mission_s256")
43
+ _MAX_JKTS = 100
44
+
45
+
46
+ class UsageQueryError(ValueError):
47
+ """The query violates the request contract (two scope keys, neither a
48
+ scope key nor ``jkts``, or malformed members)."""
49
+
50
+
51
+ def parse_usage_request(body: Any) -> tuple[str | None, str | None, list[str]]:
52
+ """Validate a usage query. Returns ``(scope_key, scope_value, jkts)``."""
53
+ if not isinstance(body, dict):
54
+ raise UsageQueryError("body must be a JSON object")
55
+ present = [k for k in _SCOPE_KEYS if k in body]
56
+ if len(present) > 1:
57
+ raise UsageQueryError("at most one scope key may appear")
58
+ jkts_raw = body.get("jkts", [])
59
+ if not isinstance(jkts_raw, list) or len(jkts_raw) > _MAX_JKTS or not all(
60
+ isinstance(j, str) and 20 <= len(j) <= 100 for j in jkts_raw
61
+ ):
62
+ raise UsageQueryError("jkts must be a short array of JWK thumbprints")
63
+ if not present and not jkts_raw:
64
+ raise UsageQueryError("a scope key or jkts is required")
65
+ scope_key = present[0] if present else None
66
+ scope_value = None
67
+ if scope_key is not None:
68
+ scope_value = body[scope_key]
69
+ if not isinstance(scope_value, str) or not scope_value:
70
+ raise UsageQueryError(f"{scope_key} must be a non-empty string")
71
+ return scope_key, scope_value, [str(j) for j in jkts_raw]
72
+
73
+
74
+ async def build_usage_response(
75
+ meter: Any,
76
+ *,
77
+ iss: str,
78
+ aud: str,
79
+ unit: str,
80
+ decimals: int,
81
+ scope_key: str | None,
82
+ scope_value: str | None,
83
+ jkts: list[str],
84
+ now: float | None = None,
85
+ ) -> dict[str, Any]:
86
+ """Assemble the response body per §Usage Response. ``iss`` scopes every
87
+ figure to tokens the calling PS issued; ``aud`` echoes who the response is
88
+ for (what stops a signed response being shown to a third party)."""
89
+ out: dict[str, Any] = {
90
+ "as_of": int(now if now is not None else time.time()),
91
+ "aud": aud,
92
+ "unit": unit,
93
+ "decimals": decimals,
94
+ }
95
+ if scope_key is not None:
96
+ out[scope_key] = scope_value
97
+ if scope_key == "sub":
98
+ counters = await meter.usage_scope(iss, str(scope_value))
99
+ if counters is not None:
100
+ out["usage"] = counters
101
+ # tenant / mission_s256: this meter holds no figure for them — the
102
+ # scope value is echoed and ``usage`` omitted, per the unrecognized
103
+ # scope rule. A backend that tracks them plugs in here.
104
+ if jkts:
105
+ out["jkts"] = await meter.usage_keys(iss, jkts)
106
+ return out
107
+
108
+
109
+ class ResponseSigner:
110
+ """Signs a usage response per §The Signed Response: an Ed25519 HTTP Sig
111
+ covering ``@status``, ``content-type``, ``content-digest``, bound to the
112
+ request via ``@authority``/``@path`` with the ``req`` parameter.
113
+
114
+ Hand-built base string: response signing with request-bound components is
115
+ beyond the request-oriented helper libraries, the component set is fixed by
116
+ the draft, and the golden tests freeze every byte of it.
117
+ """
118
+
119
+ def __init__(self, *, seed: str, jwks_url: str, label: str = "sig") -> None:
120
+ raw = b64url_decode(seed)
121
+ if len(raw) != 32:
122
+ raise ValueError("seed must be 32 bytes (base64url-encoded)")
123
+ self._key = Ed25519PrivateKey.from_private_bytes(raw)
124
+ self._jwks_url = jwks_url
125
+ self._label = label
126
+ self.public_jwk = {
127
+ "kty": "OKP", "crv": "Ed25519",
128
+ "x": base64.urlsafe_b64encode(
129
+ self._key.public_key().public_bytes_raw()
130
+ ).rstrip(b"=").decode(),
131
+ }
132
+ self.keyid = jwk_thumbprint(self.public_jwk)
133
+
134
+ def sign(self, *, status: int, content_type: str, body: bytes,
135
+ authority: str, path: str,
136
+ created: int | None = None) -> dict[str, str]:
137
+ """Return the four response headers: ``Content-Digest``,
138
+ ``Signature-Input``, ``Signature``, ``Signature-Key``."""
139
+ digest = "sha-256=:" + base64.b64encode(
140
+ hashlib.sha256(body).digest()).decode() + ":"
141
+ created = int(created if created is not None else time.time())
142
+ inner = (
143
+ '("@status" "content-type" "content-digest" '
144
+ '"@authority";req "@path";req)'
145
+ f";created={created}"
146
+ )
147
+ base = "\n".join([
148
+ f'"@status": {status}',
149
+ f'"content-type": {content_type}',
150
+ f'"content-digest": {digest}',
151
+ f'"@authority";req: {authority}',
152
+ f'"@path";req: {path}',
153
+ f'"@signature-params": {inner}',
154
+ ])
155
+ sig = base64.b64encode(self._key.sign(base.encode())).decode()
156
+ return {
157
+ "Content-Digest": digest,
158
+ "Signature-Input": f"{self._label}={inner}",
159
+ "Signature": f"{self._label}=:{sig}:",
160
+ "Signature-Key": f'{self._label}=jwks_uri; jwks_uri="{self._jwks_url}"',
161
+ }
162
+
163
+
164
+ def make_usage_endpoint(
165
+ meter: Any,
166
+ *,
167
+ authenticate_ps: Callable[[Any], Awaitable[str | None]],
168
+ unit: str,
169
+ decimals: int,
170
+ signer: ResponseSigner | None = None,
171
+ ) -> Callable[[Any], Awaitable[Any]]:
172
+ """Build an ASGI-framework-agnostic handler: ``handler(request)`` returns a
173
+ Starlette/FastAPI ``Response``. ``authenticate_ps(request)`` verifies the
174
+ calling person server's signature (jwks_uri scheme, per the AS token
175
+ endpoint rules) and returns its issuer identifier, or ``None`` to refuse —
176
+ the resource MUST only answer for values seen in tokens from that PS,
177
+ which the ``iss``-keyed counters enforce structurally."""
178
+ from starlette.responses import JSONResponse, Response
179
+
180
+ async def handler(request: Any) -> Response:
181
+ iss = await authenticate_ps(request)
182
+ if iss is None:
183
+ return JSONResponse(status_code=401, content={
184
+ "code": "PS_AUTH_REQUIRED",
185
+ "message": "Sign the query as a person server (jwks_uri scheme).",
186
+ })
187
+ try:
188
+ payload = json.loads(await request.body() or b"{}")
189
+ scope_key, scope_value, jkts = parse_usage_request(payload)
190
+ except (UsageQueryError, ValueError) as exc:
191
+ return JSONResponse(status_code=400, content={
192
+ "code": "INVALID_USAGE_QUERY", "message": str(exc)[:200]})
193
+ doc = await build_usage_response(
194
+ meter, iss=iss, aud=iss, unit=unit, decimals=decimals,
195
+ scope_key=scope_key, scope_value=scope_value, jkts=jkts)
196
+ body = json.dumps(doc, separators=(",", ":")).encode()
197
+ headers: dict[str, str] = {}
198
+ if signer is not None:
199
+ headers = signer.sign(
200
+ status=200, content_type="application/json", body=body,
201
+ authority=request.url.netloc, path=request.url.path)
202
+ return Response(content=body, media_type="application/json",
203
+ headers=headers)
204
+
205
+ return handler
206
+
207
+
208
+ def validate_budget_grant(unit: str, decimals: int,
209
+ budget_units: list[dict[str, Any]]) -> None:
210
+ """Enforce the §Resource Metadata MUSTs before minting a resource token:
211
+ a resource that declares ``budget_units`` MUST NOT issue a token whose
212
+ ``budget.unit`` is absent from the array, and MUST set ``budget.decimals``
213
+ to the declared value — the mismatch this stops is the draft's
214
+ "thousandfold error"."""
215
+ for entry in budget_units:
216
+ if entry.get("unit") == unit:
217
+ declared = entry.get("decimals")
218
+ if declared != decimals:
219
+ raise ValueError(
220
+ f"budget.decimals must be {declared} for {unit!r} "
221
+ f"(declared in budget_units), got {decimals}")
222
+ return
223
+ raise ValueError(f"unit {unit!r} is not declared in budget_units")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -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
@@ -168,6 +170,42 @@ The only thing the library cannot do for you is pricing (`price_fn`) — that
168
170
  is your domain. First known implementation of the draft; running in
169
171
  production on [get4agent.com](https://get4agent.com).
170
172
 
173
+ **The August 20 additions** are covered too:
174
+
175
+ - `insufficient-budget` refusals carry **`required`** — the refused request's
176
+ maximum cost — so the agent lowers its bound and retries instead of guessing.
177
+ - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
178
+ the header, commit when the stream ends (set `request.state.budget_cost`
179
+ mid-stream if you learn the actual), and the agent recovers the exact cost
180
+ from the next response's `remaining`.
181
+ - The **usage endpoint** (§Usage Counters) lets your PS query consumption
182
+ without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
183
+ and per-key `jkts` totals, optionally signed:
184
+
185
+ ```python
186
+ from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
187
+
188
+ handler = make_usage_endpoint(
189
+ meter,
190
+ authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
191
+ unit="USD", decimals=6,
192
+ signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
193
+ )
194
+
195
+ @app.post("/usage")
196
+ async def usage(request: Request):
197
+ return await handler(request)
198
+ ```
199
+
200
+ - `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
201
+ metadata MUSTs before you mint a resource token — the "thousandfold error"
202
+ guard.
203
+
204
+ **Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
205
+ header serializations (including `required` and the cost-omitted streaming
206
+ case), the budget object, consumption records, and a fully worked signed
207
+ usage response with a fixed key, ready for cross-implementation checks.
208
+
171
209
  ## Security model (what a naive implementation gets wrong)
172
210
 
173
211
  The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
@@ -0,0 +1,16 @@
1
+ regent_httpsig/__init__.py,sha256=Ch-PdsMDo_uKG4U5A4xklU3jbCWdJTWXHG86n5VA7dc,1709
2
+ regent_httpsig/budget.py,sha256=sjNJiFyfqLHEdnbh8e4bmqZr2gPveYynZ5eXUYpES_k,16967
3
+ regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
4
+ regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
5
+ regent_httpsig/fastapi.py,sha256=IijwDjnDKntRzavFzj2o6HM3XqvCHi2VZOv_xL_MLTA,15660
6
+ regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
7
+ regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
8
+ regent_httpsig/sfv.py,sha256=O8eK3Xyw-lewSepLJQcBKfPZAIBZCYatw4zDNDsxxYo,7407
9
+ regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
10
+ regent_httpsig/usage.py,sha256=PSIPXCtUZyh5cjn4SzwOgrXuJrwDIWu-kffJ-4NFeS0,9453
11
+ regent_httpsig/verify.py,sha256=8z-x3trF5B8P8D1aDYoqzF4R5h8BVv91CKiubFKFIiI,19915
12
+ regent_httpsig-0.5.0.dist-info/METADATA,sha256=9_2zaZrS2LBdENWDPnYVWrJhtIh9vgbG_7e7J5f3X_s,12187
13
+ regent_httpsig-0.5.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ regent_httpsig-0.5.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
15
+ regent_httpsig-0.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
16
+ regent_httpsig-0.5.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- regent_httpsig/__init__.py,sha256=TCRliXblszIoUdlNNQzPf41jA2lZ8Pu3OImylFGzaMk,1372
2
- regent_httpsig/budget.py,sha256=8B0Zb5exfjorP1pcvezpuYqHN13ytrwJG3MNOEhnvE4,10563
3
- regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
4
- regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
5
- regent_httpsig/fastapi.py,sha256=cDOk7qHdz58JO3MlTDLsyDwGEQm6t3ZO3S3hyyo7OyU,12550
6
- regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
7
- regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
8
- regent_httpsig/sfv.py,sha256=zTxPf2f0XdSu3yKl3rw_CV4_Gbpl1ZJzOM_IA9nM7H0,7076
9
- regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
10
- regent_httpsig/verify.py,sha256=8z-x3trF5B8P8D1aDYoqzF4R5h8BVv91CKiubFKFIiI,19915
11
- regent_httpsig-0.3.0.dist-info/METADATA,sha256=T0gBVK2_fZOYuWj_5ZlIYH5WJk7nn9RbmG-uWAFzcqM,10439
12
- regent_httpsig-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
- regent_httpsig-0.3.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
14
- regent_httpsig-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
15
- regent_httpsig-0.3.0.dist-info/RECORD,,