regent-httpsig 0.3.0__tar.gz → 0.4.0__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.
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/CHANGELOG.md +30 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/PKG-INFO +37 -1
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/README.md +36 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/pyproject.toml +1 -1
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/__init__.py +14 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/budget.py +98 -1
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/fastapi.py +54 -3
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sfv.py +7 -1
- regent_httpsig-0.4.0/src/regent_httpsig/usage.py +223 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/tests/test_budget.py +42 -2
- regent_httpsig-0.4.0/tests/test_usage.py +231 -0
- regent_httpsig-0.4.0/vectors/aauth-budgets-vectors.json +124 -0
- regent_httpsig-0.4.0/vectors/generate.py +157 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/.gitignore +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/LICENSE +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/SECURITY.md +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/config.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/src/regent_httpsig/verify.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/tests/test_signer.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/tests/test_vectors.py +0 -0
- {regent_httpsig-0.3.0 → regent_httpsig-0.4.0}/tests/test_verifier.py +0 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
**AAuth Budgets — the August 20 editor's-copy additions** (allocation model,
|
|
6
|
+
omitted cost, `required`, usage counters):
|
|
7
|
+
|
|
8
|
+
- **`required` member**: an `insufficient-budget` refusal now carries the
|
|
9
|
+
refused request's maximum cost in `AAuth-Budget`, so the agent's retry is a
|
|
10
|
+
calculation (fit the bound to `remaining`) rather than a search.
|
|
11
|
+
- **Streaming / cost-omitted** (§cost-omitted): a streamed response states
|
|
12
|
+
`reserved` (REQUIRED when `cost` is omitted) with `remaining` already net of
|
|
13
|
+
the hold, commits when the stream ends (`request.state.budget_cost` may be
|
|
14
|
+
set mid-stream), and the agent recovers the exact figure from the next
|
|
15
|
+
response's `remaining`. SSE is recognized automatically; other streams opt
|
|
16
|
+
in with `request.state.budget_streaming = True`.
|
|
17
|
+
- **Usage endpoint** (§Usage Counters): `make_usage_endpoint(meter, …)` —
|
|
18
|
+
scope queries (`sub` calendar counters: day/week/month/year/all_time on UTC
|
|
19
|
+
boundaries) and per-key `jkts` queries; unrecognized scope values omit
|
|
20
|
+
`usage` (never zero — a query must not reveal whether an account exists);
|
|
21
|
+
unrecognized/pruned thumbprints are omitted from `jkts` (never zero — a
|
|
22
|
+
false zero misleads an allocation decision); per-key figures pruned on 24h
|
|
23
|
+
IDLE, so a key in continuous use is never pruned; figures keyed by the
|
|
24
|
+
issuing PS, so the endpoint structurally answers only the party whose
|
|
25
|
+
tokens were accepted. PS authentication is pluggable (`authenticate_ps`).
|
|
26
|
+
- **Signed usage responses** (§The Signed Response): `ResponseSigner` — an
|
|
27
|
+
Ed25519 HTTP Sig over `@status`, `content-type`, `content-digest`, bound to
|
|
28
|
+
the request via `@authority`/`@path` with the `req` parameter.
|
|
29
|
+
- **`validate_budget_grant`**: the §Resource Metadata MUSTs (only declared
|
|
30
|
+
units; declared decimals) as a pre-mint guard against the draft's
|
|
31
|
+
"thousandfold error".
|
|
32
|
+
|
|
3
33
|
## 0.3.0
|
|
4
34
|
|
|
5
35
|
**AAuth Budgets** (draft-hardt-aauth-budgets, editor's copy) — the resource
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -168,6 +168,42 @@ The only thing the library cannot do for you is pricing (`price_fn`) — that
|
|
|
168
168
|
is your domain. First known implementation of the draft; running in
|
|
169
169
|
production on [get4agent.com](https://get4agent.com).
|
|
170
170
|
|
|
171
|
+
**The August 20 additions** are covered too:
|
|
172
|
+
|
|
173
|
+
- `insufficient-budget` refusals carry **`required`** — the refused request's
|
|
174
|
+
maximum cost — so the agent lowers its bound and retries instead of guessing.
|
|
175
|
+
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
|
|
176
|
+
the header, commit when the stream ends (set `request.state.budget_cost`
|
|
177
|
+
mid-stream if you learn the actual), and the agent recovers the exact cost
|
|
178
|
+
from the next response's `remaining`.
|
|
179
|
+
- The **usage endpoint** (§Usage Counters) lets your PS query consumption
|
|
180
|
+
without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
|
|
181
|
+
and per-key `jkts` totals, optionally signed:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
|
|
185
|
+
|
|
186
|
+
handler = make_usage_endpoint(
|
|
187
|
+
meter,
|
|
188
|
+
authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
|
|
189
|
+
unit="USD", decimals=6,
|
|
190
|
+
signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@app.post("/usage")
|
|
194
|
+
async def usage(request: Request):
|
|
195
|
+
return await handler(request)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
|
|
199
|
+
metadata MUSTs before you mint a resource token — the "thousandfold error"
|
|
200
|
+
guard.
|
|
201
|
+
|
|
202
|
+
**Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
|
|
203
|
+
header serializations (including `required` and the cost-omitted streaming
|
|
204
|
+
case), the budget object, consumption records, and a fully worked signed
|
|
205
|
+
usage response with a fixed key, ready for cross-implementation checks.
|
|
206
|
+
|
|
171
207
|
## Security model (what a naive implementation gets wrong)
|
|
172
208
|
|
|
173
209
|
The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
|
|
@@ -138,6 +138,42 @@ The only thing the library cannot do for you is pricing (`price_fn`) — that
|
|
|
138
138
|
is your domain. First known implementation of the draft; running in
|
|
139
139
|
production on [get4agent.com](https://get4agent.com).
|
|
140
140
|
|
|
141
|
+
**The August 20 additions** are covered too:
|
|
142
|
+
|
|
143
|
+
- `insufficient-budget` refusals carry **`required`** — the refused request's
|
|
144
|
+
maximum cost — so the agent lowers its bound and retries instead of guessing.
|
|
145
|
+
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
|
|
146
|
+
the header, commit when the stream ends (set `request.state.budget_cost`
|
|
147
|
+
mid-stream if you learn the actual), and the agent recovers the exact cost
|
|
148
|
+
from the next response's `remaining`.
|
|
149
|
+
- The **usage endpoint** (§Usage Counters) lets your PS query consumption
|
|
150
|
+
without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
|
|
151
|
+
and per-key `jkts` totals, optionally signed:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
|
|
155
|
+
|
|
156
|
+
handler = make_usage_endpoint(
|
|
157
|
+
meter,
|
|
158
|
+
authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
|
|
159
|
+
unit="USD", decimals=6,
|
|
160
|
+
signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@app.post("/usage")
|
|
164
|
+
async def usage(request: Request):
|
|
165
|
+
return await handler(request)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
|
|
169
|
+
metadata MUSTs before you mint a resource token — the "thousandfold error"
|
|
170
|
+
guard.
|
|
171
|
+
|
|
172
|
+
**Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
|
|
173
|
+
header serializations (including `required` and the cost-omitted streaming
|
|
174
|
+
case), the budget object, consumption records, and a fully worked signed
|
|
175
|
+
usage response with a fixed key, ready for cross-implementation checks.
|
|
176
|
+
|
|
141
177
|
## Security model (what a naive implementation gets wrong)
|
|
142
178
|
|
|
143
179
|
The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-httpsig"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -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",
|
|
@@ -21,6 +21,7 @@ import asyncio
|
|
|
21
21
|
import itertools
|
|
22
22
|
import time
|
|
23
23
|
from collections.abc import Mapping
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
24
25
|
from dataclasses import dataclass, field
|
|
25
26
|
from typing import Any
|
|
26
27
|
|
|
@@ -109,6 +110,48 @@ class _Pool:
|
|
|
109
110
|
last_activity: float = 0.0
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
@dataclass
|
|
114
|
+
class _ScopeCounters:
|
|
115
|
+
"""Calendar counters for one usage scope (draft §calendar-counters): running
|
|
116
|
+
integers bucketed on UTC boundaries — no per-record history is kept."""
|
|
117
|
+
|
|
118
|
+
all_time: int = 0
|
|
119
|
+
day: int = 0
|
|
120
|
+
week: int = 0
|
|
121
|
+
month: int = 0
|
|
122
|
+
year: int = 0
|
|
123
|
+
day_start: float = 0.0
|
|
124
|
+
week_start: float = 0.0
|
|
125
|
+
month_start: float = 0.0
|
|
126
|
+
year_start: float = 0.0
|
|
127
|
+
|
|
128
|
+
def add(self, amount: int, wall: float) -> None:
|
|
129
|
+
self._roll(wall)
|
|
130
|
+
self.all_time += amount
|
|
131
|
+
self.day += amount
|
|
132
|
+
self.week += amount
|
|
133
|
+
self.month += amount
|
|
134
|
+
self.year += amount
|
|
135
|
+
|
|
136
|
+
def snapshot(self, wall: float) -> dict[str, int]:
|
|
137
|
+
self._roll(wall)
|
|
138
|
+
return {"day": self.day, "week": self.week, "month": self.month,
|
|
139
|
+
"year": self.year, "all_time": self.all_time}
|
|
140
|
+
|
|
141
|
+
def _roll(self, wall: float) -> None:
|
|
142
|
+
dt = datetime.fromtimestamp(wall, tz=UTC)
|
|
143
|
+
day = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
144
|
+
week = day - timedelta(days=dt.weekday()) # Monday 00:00 UTC (ISO 8601)
|
|
145
|
+
month = day.replace(day=1)
|
|
146
|
+
year = month.replace(month=1)
|
|
147
|
+
for name, start in (("day", day), ("week", week),
|
|
148
|
+
("month", month), ("year", year)):
|
|
149
|
+
ts = start.timestamp()
|
|
150
|
+
if getattr(self, f"{name}_start") < ts:
|
|
151
|
+
setattr(self, name, 0)
|
|
152
|
+
setattr(self, f"{name}_start", ts)
|
|
153
|
+
|
|
154
|
+
|
|
112
155
|
class InMemoryMeter:
|
|
113
156
|
"""Single-process meter (asyncio-safe). Right for a single-instance service;
|
|
114
157
|
multi-replica deployments need a shared backend behind the same interface.
|
|
@@ -119,12 +162,23 @@ class InMemoryMeter:
|
|
|
119
162
|
"""
|
|
120
163
|
|
|
121
164
|
def __init__(self, *, reservation_ttl: float = 120.0,
|
|
122
|
-
retention_seconds: float = 7200.0
|
|
165
|
+
retention_seconds: float = 7200.0,
|
|
166
|
+
usage_key_retention: float = 86400.0) -> None:
|
|
123
167
|
self._pools: dict[MeterKey, _Pool] = {}
|
|
124
168
|
self._lock = asyncio.Lock()
|
|
125
169
|
self._rids = itertools.count(1)
|
|
126
170
|
self._reservation_ttl = reservation_ttl
|
|
127
171
|
self._retention = retention_seconds
|
|
172
|
+
# Usage counters (draft §usage-counters) — wall-clock, keyed by the
|
|
173
|
+
# issuing PS so the endpoint only answers the party whose tokens we
|
|
174
|
+
# accepted. Scope counters never expire (all_time reaches as far back
|
|
175
|
+
# as the resource retains); per-key figures are pruned on IDLE time —
|
|
176
|
+
# "SHOULD retain … at least 24 hours after that key's last metered
|
|
177
|
+
# request" — so a key in continuous use is never pruned.
|
|
178
|
+
self._usage_key_retention = usage_key_retention
|
|
179
|
+
self._scope_usage: dict[tuple[str, str], _ScopeCounters] = {} # (iss, sub)
|
|
180
|
+
self._key_usage: dict[tuple[str, str], tuple[int, float]] = {} # (iss, jkt) -> (total, last_wall)
|
|
181
|
+
self._metering_unit: tuple[str, int] | None = None
|
|
128
182
|
|
|
129
183
|
# ── internals (call under lock) ──────────────────────────────────────────
|
|
130
184
|
|
|
@@ -137,6 +191,7 @@ class InMemoryMeter:
|
|
|
137
191
|
if deadline <= now:
|
|
138
192
|
pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
|
|
139
193
|
del pool.reservations[rid]
|
|
194
|
+
self._record_usage(key, pool, jti, amount)
|
|
140
195
|
# Expired grants leave the pool; their consumption records remain for
|
|
141
196
|
# budget_consumed reporting until the retention window passes.
|
|
142
197
|
for jti, (_, exp) in list(pool.grants.items()):
|
|
@@ -210,6 +265,8 @@ class InMemoryMeter:
|
|
|
210
265
|
cost = min(max(actual, 0), held[1] if held else res.amount)
|
|
211
266
|
pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
|
|
212
267
|
pool.last_activity = now
|
|
268
|
+
if cost > 0:
|
|
269
|
+
self._record_usage(res.key, pool, res.jti, cost)
|
|
213
270
|
return self._remaining(pool)
|
|
214
271
|
|
|
215
272
|
async def release(self, res: Reservation) -> int:
|
|
@@ -225,6 +282,46 @@ class InMemoryMeter:
|
|
|
225
282
|
pool = self._purge(key, time.monotonic())
|
|
226
283
|
return 0 if pool is None else self._remaining(pool)
|
|
227
284
|
|
|
285
|
+
def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
|
|
286
|
+
amount: int) -> None:
|
|
287
|
+
"""Post a committed cost to the usage counters (call under lock).
|
|
288
|
+
Wall-clock, because calendar boundaries are UTC by definition."""
|
|
289
|
+
if amount <= 0:
|
|
290
|
+
return
|
|
291
|
+
wall = time.time()
|
|
292
|
+
iss, sub, _aud = key
|
|
293
|
+
self._metering_unit = self._metering_unit or (pool.unit, pool.decimals)
|
|
294
|
+
self._scope_usage.setdefault((iss, sub), _ScopeCounters()).add(amount, wall)
|
|
295
|
+
jkt = pool.jkt_of.get(jti)
|
|
296
|
+
if jkt:
|
|
297
|
+
total, _ = self._key_usage.get((iss, jkt), (0, 0.0))
|
|
298
|
+
self._key_usage[(iss, jkt)] = (total + amount, wall)
|
|
299
|
+
|
|
300
|
+
async def usage_scope(self, iss: str, sub: str) -> dict[str, int] | None:
|
|
301
|
+
"""Calendar counters for a ``sub`` scope query, or ``None`` when the
|
|
302
|
+
resource holds no figure — the endpoint then omits ``usage``, keeping
|
|
303
|
+
"never seen" indistinguishable from "nothing consumed"."""
|
|
304
|
+
async with self._lock:
|
|
305
|
+
counters = self._scope_usage.get((iss, sub))
|
|
306
|
+
return None if counters is None else counters.snapshot(time.time())
|
|
307
|
+
|
|
308
|
+
async def usage_keys(self, iss: str, jkts: list[str]) -> dict[str, int]:
|
|
309
|
+
"""Per-key totals for a ``jkts`` query. Unrecognized or pruned keys are
|
|
310
|
+
OMITTED, never reported as zero — absence means "cannot answer", a
|
|
311
|
+
present zero would be a wrong answer to an allocation decision."""
|
|
312
|
+
async with self._lock:
|
|
313
|
+
wall = time.time()
|
|
314
|
+
for pair, (_, last) in list(self._key_usage.items()):
|
|
315
|
+
if wall - last > self._usage_key_retention:
|
|
316
|
+
del self._key_usage[pair]
|
|
317
|
+
return {jkt: self._key_usage[(iss, jkt)][0]
|
|
318
|
+
for jkt in jkts if (iss, jkt) in self._key_usage}
|
|
319
|
+
|
|
320
|
+
def metering_unit(self) -> tuple[str, int] | None:
|
|
321
|
+
"""The one unit every usage figure is denominated in (draft §one-unit),
|
|
322
|
+
or ``None`` before the first commit."""
|
|
323
|
+
return self._metering_unit
|
|
324
|
+
|
|
228
325
|
async def consumed_records(self, key: MeterKey,
|
|
229
326
|
jkt: str | None = None) -> list[dict[str, Any]]:
|
|
230
327
|
"""Per-token consumption for the resource token's ``budget_consumed``
|
|
@@ -224,6 +224,9 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
224
224
|
return await self._refusal_with_token(
|
|
225
225
|
reason=reason, envelope=envelope, remaining=outcome.remaining,
|
|
226
226
|
key=key, jkt=sig.keyid,
|
|
227
|
+
# `required` rides only on insufficient-budget: what THIS
|
|
228
|
+
# request needed, so the agent can lower its bound and retry.
|
|
229
|
+
required=int(max_cost) if reason == "insufficient-budget" else None,
|
|
227
230
|
)
|
|
228
231
|
|
|
229
232
|
reservation: Reservation = outcome
|
|
@@ -237,6 +240,21 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
237
240
|
# Nothing was served — the envelope is not charged for errors.
|
|
238
241
|
remaining = await self._meter.release(reservation)
|
|
239
242
|
cost = 0
|
|
243
|
+
elif self._is_streamed(request, response):
|
|
244
|
+
# Cost-omitted mode (draft §cost-omitted): a streamed response's
|
|
245
|
+
# actual cost is known only when the stream ends, and this runtime
|
|
246
|
+
# sends no trailers. We state what we HOLD — `reserved`, REQUIRED
|
|
247
|
+
# when `cost` is omitted — with `remaining` already net of the
|
|
248
|
+
# hold, and commit when the stream completes. The agent recovers
|
|
249
|
+
# the exact figure from the next response's `remaining`.
|
|
250
|
+
remaining = await self._meter.remaining(key)
|
|
251
|
+
response.headers["AAuth-Budget"] = build_aauth_budget_header(
|
|
252
|
+
remaining=remaining, reserved=int(max_cost),
|
|
253
|
+
unit=envelope.unit, decimals=envelope.decimals,
|
|
254
|
+
)
|
|
255
|
+
self._commit_after_stream(request, response, reservation,
|
|
256
|
+
int(max_cost))
|
|
257
|
+
return response
|
|
240
258
|
else:
|
|
241
259
|
actual = getattr(request.state, "budget_cost", None)
|
|
242
260
|
cost = int(actual) if actual is not None else int(max_cost)
|
|
@@ -247,6 +265,36 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
247
265
|
)
|
|
248
266
|
return response
|
|
249
267
|
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _is_streamed(request: Request, response: Response) -> bool:
|
|
270
|
+
"""A handler opts in with ``request.state.budget_streaming = True``;
|
|
271
|
+
SSE responses are recognized on their own."""
|
|
272
|
+
if getattr(request.state, "budget_streaming", False):
|
|
273
|
+
return True
|
|
274
|
+
ctype = response.headers.get("content-type", "")
|
|
275
|
+
return ctype.startswith("text/event-stream")
|
|
276
|
+
|
|
277
|
+
def _commit_after_stream(self, request: Request, response: Response,
|
|
278
|
+
reservation: Reservation, max_cost: int) -> None:
|
|
279
|
+
"""Wrap the body iterator: commit when the stream ends (the handler may
|
|
280
|
+
set ``request.state.budget_cost`` while streaming), release the unspent
|
|
281
|
+
remainder; a broken stream commits the full hold — conservative, per
|
|
282
|
+
the reservation-timeout rule."""
|
|
283
|
+
inner = response.body_iterator # type: ignore[attr-defined]
|
|
284
|
+
|
|
285
|
+
async def metered() -> Any:
|
|
286
|
+
ok = False
|
|
287
|
+
try:
|
|
288
|
+
async for chunk in inner:
|
|
289
|
+
yield chunk
|
|
290
|
+
ok = True
|
|
291
|
+
finally:
|
|
292
|
+
actual = getattr(request.state, "budget_cost", None)
|
|
293
|
+
cost = int(actual) if (ok and actual is not None) else max_cost
|
|
294
|
+
await self._meter.commit(reservation, cost)
|
|
295
|
+
|
|
296
|
+
response.body_iterator = metered() # type: ignore[attr-defined]
|
|
297
|
+
|
|
250
298
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
251
299
|
|
|
252
300
|
async def _verified(self, request: Request) -> VerifiedSignature | None:
|
|
@@ -264,6 +312,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
264
312
|
async def _refusal_with_token(
|
|
265
313
|
self, *, reason: str, envelope: BudgetClaim,
|
|
266
314
|
remaining: int, key: MeterKey, jkt: str | None = None,
|
|
315
|
+
required: int | None = None,
|
|
267
316
|
) -> Response:
|
|
268
317
|
token: str | None = None
|
|
269
318
|
if self._resource_token is not None:
|
|
@@ -273,12 +322,13 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
273
322
|
except Exception: # noqa: BLE001 — refusal must not fail on the extras
|
|
274
323
|
logger.warning("resource_token_provider failed", exc_info=True)
|
|
275
324
|
return self._refusal(reason=reason, envelope=envelope,
|
|
276
|
-
remaining=remaining, resource_token=token
|
|
325
|
+
remaining=remaining, resource_token=token,
|
|
326
|
+
required=required)
|
|
277
327
|
|
|
278
328
|
def _refusal(
|
|
279
329
|
self, *, reason: str | None, envelope: BudgetClaim | None,
|
|
280
330
|
remaining: int | None, key: MeterKey | None = None,
|
|
281
|
-
resource_token: str | None = None,
|
|
331
|
+
resource_token: str | None = None, required: int | None = None,
|
|
282
332
|
) -> Response:
|
|
283
333
|
headers = {
|
|
284
334
|
"AAuth-Requirement": build_aauth_requirement(
|
|
@@ -288,7 +338,8 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
288
338
|
}
|
|
289
339
|
if remaining is not None and envelope is not None:
|
|
290
340
|
headers["AAuth-Budget"] = build_aauth_budget_header(
|
|
291
|
-
remaining=remaining,
|
|
341
|
+
remaining=remaining, required=required,
|
|
342
|
+
unit=envelope.unit, decimals=envelope.decimals,
|
|
292
343
|
)
|
|
293
344
|
code = "AUTH_TOKEN_REQUIRED" if reason is None else reason.upper().replace("-", "_")
|
|
294
345
|
return JSONResponse(
|
|
@@ -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")
|
|
@@ -276,7 +276,8 @@ def _app(verifier: HttpsigVerifier, **mw_kwargs: Any) -> FastAPI:
|
|
|
276
276
|
app.add_middleware(
|
|
277
277
|
BudgetMiddleware,
|
|
278
278
|
verifier=verifier,
|
|
279
|
-
price_fn=lambda request:
|
|
279
|
+
price_fn=lambda request: (
|
|
280
|
+
300 if request.url.path in ("/v1/search", "/v1/stream") else None),
|
|
280
281
|
**mw_kwargs,
|
|
281
282
|
)
|
|
282
283
|
|
|
@@ -293,6 +294,17 @@ def _app(verifier: HttpsigVerifier, **mw_kwargs: Any) -> FastAPI:
|
|
|
293
294
|
async def free() -> dict[str, bool]:
|
|
294
295
|
return {"ok": True}
|
|
295
296
|
|
|
297
|
+
@app.post("/v1/stream")
|
|
298
|
+
async def stream(request: Request): # type: ignore[no-untyped-def]
|
|
299
|
+
from starlette.responses import StreamingResponse
|
|
300
|
+
|
|
301
|
+
async def gen(): # type: ignore[no-untyped-def]
|
|
302
|
+
yield b"data: one\n\n"
|
|
303
|
+
request.state.budget_cost = 120 # actual, learned mid-stream
|
|
304
|
+
yield b"data: two\n\n"
|
|
305
|
+
|
|
306
|
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
307
|
+
|
|
296
308
|
return app
|
|
297
309
|
|
|
298
310
|
|
|
@@ -365,7 +377,8 @@ async def test_middleware_refuses_when_insufficient(
|
|
|
365
377
|
assert (r2.headers["AAuth-Requirement"] ==
|
|
366
378
|
'requirement=auth-token;resource-token="resource.token.here"'
|
|
367
379
|
";reason=insufficient-budget")
|
|
368
|
-
assert r2.headers["AAuth-Budget"] ==
|
|
380
|
+
assert (r2.headers["AAuth-Budget"] ==
|
|
381
|
+
'remaining=200, required=300, unit="KZT", decimals=2')
|
|
369
382
|
assert provider_calls and provider_calls[0][0] == (PS_ISS, "owner-1", RESOURCE)
|
|
370
383
|
assert provider_calls[0][1] == [{"jti": "at-1", "consumed": 300}]
|
|
371
384
|
|
|
@@ -447,3 +460,30 @@ async def test_middleware_releases_on_error_response(
|
|
|
447
460
|
assert r.status_code == 422
|
|
448
461
|
# Nothing served → envelope not charged.
|
|
449
462
|
assert r.headers["AAuth-Budget"] == 'cost=0, remaining=1000, unit="KZT", decimals=2'
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def test_streaming_cost_omitted_reserved_math(
|
|
466
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""§cost-omitted: no trailer runtime — the header states `reserved` (and no
|
|
469
|
+
`cost`), `remaining` is net of the hold, and the agent recovers the exact
|
|
470
|
+
figure from the NEXT response: prev remaining + reserved − next remaining."""
|
|
471
|
+
ps_priv, ps_jwk = _ps_pair()
|
|
472
|
+
agent = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
473
|
+
token = _auth_token(ps_priv, agent, amount=1000)
|
|
474
|
+
app = _app(_verifier(ps_jwk, monkeypatch))
|
|
475
|
+
|
|
476
|
+
r1 = await _post(app, "/v1/stream", _signed_headers(agent, token, "/v1/stream"))
|
|
477
|
+
assert r1.status_code == 200
|
|
478
|
+
d1 = SFDictionary(); d1.parse(r1.headers["AAuth-Budget"].encode())
|
|
479
|
+
assert "cost" not in d1 # omitted — no trailer runtime
|
|
480
|
+
assert int(str(d1["reserved"].value)) == 300 # REQUIRED when cost omitted
|
|
481
|
+
assert int(str(d1["remaining"].value)) == 700 # net of the hold
|
|
482
|
+
assert r1.text.count("data:") == 2
|
|
483
|
+
|
|
484
|
+
# Stream ended → the actual 120 was committed, 180 returned to the grant.
|
|
485
|
+
r2 = await _post(app, "/v1/search", _signed_headers(agent, token, "/v1/search"))
|
|
486
|
+
d2 = SFDictionary(); d2.parse(r2.headers["AAuth-Budget"].encode())
|
|
487
|
+
next_remaining = int(str(d2["remaining"].value)) + 300 # add back r2's own cost
|
|
488
|
+
recovered = 1000 + 300 - 700 - (1000 - next_remaining) # draft's subtraction…
|
|
489
|
+
assert 700 + 300 - next_remaining == 120 # …prev + reserved − next = cost
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""AAuth Budgets §Usage Counters + the Aug-20 additions (required, streaming).
|
|
2
|
+
|
|
3
|
+
Every rule these tests pin comes with the draft's reasoning in the test name
|
|
4
|
+
or a comment — they double as our conformance notes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from starlette.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
from regent_httpsig import (
|
|
21
|
+
BudgetClaim,
|
|
22
|
+
InMemoryMeter,
|
|
23
|
+
ResponseSigner,
|
|
24
|
+
UsageQueryError,
|
|
25
|
+
build_aauth_budget_header,
|
|
26
|
+
build_usage_response,
|
|
27
|
+
make_usage_endpoint,
|
|
28
|
+
parse_usage_request,
|
|
29
|
+
)
|
|
30
|
+
from regent_httpsig.budget import MeterKey
|
|
31
|
+
from regent_httpsig.jwk import b64url, b64url_decode
|
|
32
|
+
from regent_httpsig.sfv import SFDictionary
|
|
33
|
+
|
|
34
|
+
KEY: MeterKey = ("https://ps.example", "sub-1", "https://api.example")
|
|
35
|
+
CLAIM = BudgetClaim.parse({"budget": {"amount": 10_000, "unit": "USD", "decimals": 2}})
|
|
36
|
+
JKT_A = "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
|
|
37
|
+
JKT_B = "0ZcOCORZNYy-DWpqq30BbmLzO1Yw3ZQhIgnHZQKxNVE"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _spend(meter: InMemoryMeter, jti: str, jkt: str, cost: int) -> None:
|
|
41
|
+
await meter.observe_grant(KEY, jti, CLAIM, time.time() + 3600, jkt=jkt)
|
|
42
|
+
res = await meter.reserve(KEY, jti, cost)
|
|
43
|
+
await meter.commit(res, cost)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── required member (header) ─────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def test_header_golden_with_required() -> None:
|
|
49
|
+
out = build_aauth_budget_header(remaining=300, required=750,
|
|
50
|
+
unit="USD", decimals=2)
|
|
51
|
+
assert out == 'remaining=300, required=750, unit="USD", decimals=2'
|
|
52
|
+
parsed = SFDictionary()
|
|
53
|
+
parsed.parse(out.encode())
|
|
54
|
+
assert int(parsed["required"].value) == 750 # real SFV parser round-trip
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_header_member_order_is_the_drafts() -> None:
|
|
58
|
+
out = build_aauth_budget_header(remaining=1, cost=2, reserved=3, required=4)
|
|
59
|
+
assert out == "cost=2, remaining=1, reserved=3, required=4"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── usage request validation (§usage-request) ────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def test_query_two_scope_keys_is_an_error() -> None:
|
|
65
|
+
with pytest.raises(UsageQueryError):
|
|
66
|
+
parse_usage_request({"sub": "s", "tenant": "t"})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_query_neither_scope_nor_jkts_is_an_error() -> None:
|
|
70
|
+
with pytest.raises(UsageQueryError):
|
|
71
|
+
parse_usage_request({})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_query_scope_and_jkts_may_both_appear() -> None:
|
|
75
|
+
scope_key, scope_value, jkts = parse_usage_request(
|
|
76
|
+
{"sub": "s", "jkts": [JKT_A]})
|
|
77
|
+
assert (scope_key, scope_value, jkts) == ("sub", "s", [JKT_A])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── calendar counters + per-key figures ──────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_scope_counters_accumulate_and_echo() -> None:
|
|
84
|
+
meter = InMemoryMeter()
|
|
85
|
+
await _spend(meter, "jti-1", JKT_A, 400)
|
|
86
|
+
await _spend(meter, "jti-2", JKT_B, 100)
|
|
87
|
+
doc = await build_usage_response(
|
|
88
|
+
meter, iss=KEY[0], aud=KEY[0], unit="USD", decimals=2,
|
|
89
|
+
scope_key="sub", scope_value="sub-1", jkts=[])
|
|
90
|
+
assert doc["sub"] == "sub-1"
|
|
91
|
+
assert doc["usage"]["all_time"] == 500
|
|
92
|
+
assert doc["usage"]["day"] == 500 # committed just now, same UTC day
|
|
93
|
+
assert doc["unit"] == "USD" and doc["decimals"] == 2 and doc["aud"] == KEY[0]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_unknown_scope_value_omits_usage_not_zero() -> None:
|
|
98
|
+
""""Never seen" must be indistinguishable from "nothing consumed" — a
|
|
99
|
+
query cannot be used to discover whether a person holds an account."""
|
|
100
|
+
meter = InMemoryMeter()
|
|
101
|
+
doc = await build_usage_response(
|
|
102
|
+
meter, iss=KEY[0], aud=KEY[0], unit="USD", decimals=2,
|
|
103
|
+
scope_key="sub", scope_value="nobody", jkts=[])
|
|
104
|
+
assert "usage" not in doc and doc["sub"] == "nobody"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_per_key_totals_and_unknown_key_omitted() -> None:
|
|
109
|
+
"""Unknown/pruned thumbprints are OMITTED, never zero: a present zero
|
|
110
|
+
would be a wrong answer to an allocation decision."""
|
|
111
|
+
meter = InMemoryMeter()
|
|
112
|
+
await _spend(meter, "jti-1", JKT_A, 400)
|
|
113
|
+
doc = await build_usage_response(
|
|
114
|
+
meter, iss=KEY[0], aud=KEY[0], unit="USD", decimals=2,
|
|
115
|
+
scope_key=None, scope_value=None, jkts=[JKT_A, JKT_B])
|
|
116
|
+
assert doc["jkts"] == {JKT_A: 400}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_per_key_pruned_on_idle_not_age() -> None:
|
|
121
|
+
meter = InMemoryMeter(usage_key_retention=0.05)
|
|
122
|
+
await _spend(meter, "jti-1", JKT_A, 100)
|
|
123
|
+
await asyncio.sleep(0.08)
|
|
124
|
+
assert await meter.usage_keys(KEY[0], [JKT_A]) == {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_usage_scoped_to_the_calling_ps() -> None:
|
|
129
|
+
"""The resource only answers for tokens the calling PS issued — figures
|
|
130
|
+
are keyed by iss, so another PS simply holds no records."""
|
|
131
|
+
meter = InMemoryMeter()
|
|
132
|
+
await _spend(meter, "jti-1", JKT_A, 400)
|
|
133
|
+
assert await meter.usage_keys("https://other-ps.example", [JKT_A]) == {}
|
|
134
|
+
assert await meter.usage_scope("https://other-ps.example", "sub-1") is None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_expired_reservation_reaches_usage_counters() -> None:
|
|
139
|
+
"""The conservative rule (unresolved reservation = consumed) must show up
|
|
140
|
+
in usage too, or the PS's pull path would under-count a crashed handler."""
|
|
141
|
+
meter = InMemoryMeter(reservation_ttl=0.01)
|
|
142
|
+
await meter.observe_grant(KEY, "jti-1", CLAIM, time.time() + 3600, jkt=JKT_A)
|
|
143
|
+
await meter.reserve(KEY, "jti-1", 700)
|
|
144
|
+
await asyncio.sleep(0.02)
|
|
145
|
+
await meter.remaining(KEY) # triggers the purge
|
|
146
|
+
assert (await meter.usage_keys(KEY[0], [JKT_A]))[JKT_A] == 700
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── the endpoint handler ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def _app(meter: InMemoryMeter, iss: str | None,
|
|
152
|
+
signer: ResponseSigner | None = None) -> FastAPI:
|
|
153
|
+
async def auth(_request: Request) -> str | None:
|
|
154
|
+
return iss
|
|
155
|
+
|
|
156
|
+
handler = make_usage_endpoint(meter, authenticate_ps=auth,
|
|
157
|
+
unit="USD", decimals=2, signer=signer)
|
|
158
|
+
app = FastAPI()
|
|
159
|
+
|
|
160
|
+
@app.post("/usage")
|
|
161
|
+
async def usage(request: Request): # type: ignore[no-untyped-def]
|
|
162
|
+
return await handler(request)
|
|
163
|
+
|
|
164
|
+
return app
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_endpoint_refuses_unauthenticated_ps() -> None:
|
|
168
|
+
client = TestClient(_app(InMemoryMeter(), iss=None))
|
|
169
|
+
assert client.post("/usage", json={"sub": "s"}).status_code == 401
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_endpoint_rejects_bad_query() -> None:
|
|
173
|
+
client = TestClient(_app(InMemoryMeter(), iss=KEY[0]))
|
|
174
|
+
r = client.post("/usage", json={"sub": "a", "tenant": "b"})
|
|
175
|
+
assert r.status_code == 400 and r.json()["code"] == "INVALID_USAGE_QUERY"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_endpoint_end_to_end_signed() -> None:
|
|
180
|
+
meter = InMemoryMeter()
|
|
181
|
+
await _spend(meter, "jti-1", JKT_A, 400)
|
|
182
|
+
seed = b64url(b"\x07" * 32)
|
|
183
|
+
signer = ResponseSigner(seed=seed, jwks_url="https://api.example/jwks.json")
|
|
184
|
+
client = TestClient(_app(meter, iss=KEY[0], signer=signer))
|
|
185
|
+
r = client.post("/usage", json={"sub": "sub-1", "jkts": [JKT_A, JKT_B]})
|
|
186
|
+
assert r.status_code == 200
|
|
187
|
+
doc = r.json()
|
|
188
|
+
assert doc["usage"]["all_time"] == 400 and doc["jkts"] == {JKT_A: 400}
|
|
189
|
+
|
|
190
|
+
# The signature covers @status/content-type/content-digest + the request's
|
|
191
|
+
# @authority/@path (req-bound) — verify it from scratch with the public key.
|
|
192
|
+
digest = "sha-256=:" + base64.b64encode(
|
|
193
|
+
__import__("hashlib").sha256(r.content).digest()).decode() + ":"
|
|
194
|
+
assert r.headers["content-digest"] == digest
|
|
195
|
+
inner = r.headers["signature-input"].split("=", 1)[1]
|
|
196
|
+
base = "\n".join([
|
|
197
|
+
'"@status": 200',
|
|
198
|
+
'"content-type": application/json',
|
|
199
|
+
f'"content-digest": {digest}',
|
|
200
|
+
'"@authority";req: testserver',
|
|
201
|
+
'"@path";req: /usage',
|
|
202
|
+
f'"@signature-params": {inner}',
|
|
203
|
+
])
|
|
204
|
+
sig_b64 = r.headers["signature"].split("=", 1)[1].strip(":")
|
|
205
|
+
pub = Ed25519PublicKey.from_public_bytes(
|
|
206
|
+
b64url_decode(signer.public_jwk["x"]))
|
|
207
|
+
pub.verify(base64.b64decode(sig_b64), base.encode()) # raises on mismatch
|
|
208
|
+
assert 'jwks_uri="https://api.example/jwks.json"' in r.headers["signature-key"]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_validate_budget_grant_enforces_declared_units() -> None:
|
|
212
|
+
units = [{"unit": "USD", "decimals": 6, "max": 10_000_000}]
|
|
213
|
+
from regent_httpsig import validate_budget_grant
|
|
214
|
+
validate_budget_grant("USD", 6, units) # declared → fine
|
|
215
|
+
with pytest.raises(ValueError):
|
|
216
|
+
validate_budget_grant("USD", 2, units) # the thousandfold error
|
|
217
|
+
with pytest.raises(ValueError):
|
|
218
|
+
validate_budget_grant("EUR", 2, units) # undeclared unit
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_vectors_are_current() -> None:
|
|
222
|
+
"""The published vectors must match what the code emits today."""
|
|
223
|
+
import pathlib
|
|
224
|
+
import subprocess
|
|
225
|
+
import sys
|
|
226
|
+
root = pathlib.Path(__file__).resolve().parent.parent
|
|
227
|
+
before = (root / "vectors" / "aauth-budgets-vectors.json").read_text()
|
|
228
|
+
subprocess.run([sys.executable, str(root / "vectors" / "generate.py")],
|
|
229
|
+
check=True, capture_output=True)
|
|
230
|
+
after = (root / "vectors" / "aauth-budgets-vectors.json").read_text()
|
|
231
|
+
assert before == after
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "regent-httpsig, first implementation of draft-hardt-aauth-budgets (editor's copy, 2026-08-20)",
|
|
3
|
+
"headers": {
|
|
4
|
+
"aauth_budget": [
|
|
5
|
+
{
|
|
6
|
+
"name": "success-with-cost",
|
|
7
|
+
"input": {
|
|
8
|
+
"remaining": 1568800,
|
|
9
|
+
"cost": 221200,
|
|
10
|
+
"unit": "USD",
|
|
11
|
+
"decimals": 6
|
|
12
|
+
},
|
|
13
|
+
"expected": "cost=221200, remaining=1568800, unit=\"USD\", decimals=6"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "refusal-insufficient-with-required",
|
|
17
|
+
"note": "`required` rides only on insufficient-budget refusals: what THIS request needed, so the retry is a calculation.",
|
|
18
|
+
"input": {
|
|
19
|
+
"remaining": 300,
|
|
20
|
+
"required": 750,
|
|
21
|
+
"unit": "USD",
|
|
22
|
+
"decimals": 6
|
|
23
|
+
},
|
|
24
|
+
"expected": "remaining=300, required=750, unit=\"USD\", decimals=6"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "streaming-cost-omitted",
|
|
28
|
+
"note": "No-trailer runtime: cost omitted, reserved REQUIRED, remaining already net of the hold. The agent recovers cost = prev remaining + reserved - next remaining.",
|
|
29
|
+
"input": {
|
|
30
|
+
"remaining": 1568800,
|
|
31
|
+
"reserved": 431200,
|
|
32
|
+
"unit": "USD",
|
|
33
|
+
"decimals": 6
|
|
34
|
+
},
|
|
35
|
+
"expected": "remaining=1568800, reserved=431200, unit=\"USD\", decimals=6"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "exhaustion-zero",
|
|
39
|
+
"input": {
|
|
40
|
+
"remaining": 0,
|
|
41
|
+
"unit": "USD",
|
|
42
|
+
"decimals": 6
|
|
43
|
+
},
|
|
44
|
+
"expected": "remaining=0, unit=\"USD\", decimals=6"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"aauth_requirement": [
|
|
48
|
+
{
|
|
49
|
+
"name": "refusal-with-resource-token",
|
|
50
|
+
"input": {
|
|
51
|
+
"reason": "budget-exhausted",
|
|
52
|
+
"resource_token": "eyJexample"
|
|
53
|
+
},
|
|
54
|
+
"expected": "requirement=auth-token;resource-token=\"eyJexample\";reason=budget-exhausted"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"budget_claim": {
|
|
59
|
+
"shape": {
|
|
60
|
+
"amount": 5000000,
|
|
61
|
+
"unit": "USD",
|
|
62
|
+
"decimals": 6
|
|
63
|
+
},
|
|
64
|
+
"notes": [
|
|
65
|
+
"amount: non-negative integer, bounded by the 15-digit SF limit",
|
|
66
|
+
"decimals is NOT constrained to the ISO 4217 minor unit",
|
|
67
|
+
"all three members REQUIRED wherever `budget` appears"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
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
|
+
],
|
|
81
|
+
"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"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"signed_usage_response": {
|
|
88
|
+
"signing_key": {
|
|
89
|
+
"seed_b64url": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
|
|
90
|
+
"public_jwk": {
|
|
91
|
+
"kty": "OKP",
|
|
92
|
+
"crv": "Ed25519",
|
|
93
|
+
"x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"
|
|
94
|
+
},
|
|
95
|
+
"keyid_rfc7638": "1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y"
|
|
96
|
+
},
|
|
97
|
+
"request_context": {
|
|
98
|
+
"authority": "api.example",
|
|
99
|
+
"path": "/usage"
|
|
100
|
+
},
|
|
101
|
+
"body": {
|
|
102
|
+
"as_of": 1756500000,
|
|
103
|
+
"aud": "https://ps.example",
|
|
104
|
+
"unit": "USD",
|
|
105
|
+
"decimals": 6,
|
|
106
|
+
"sub": "8f14e45fceea167a5a36dedd4bea2543",
|
|
107
|
+
"usage": {
|
|
108
|
+
"day": 1243180,
|
|
109
|
+
"all_time": 61438050
|
|
110
|
+
},
|
|
111
|
+
"jkts": {
|
|
112
|
+
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs": 38215600
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"body_canonical_json": "{\"as_of\":1756500000,\"aud\":\"https://ps.example\",\"unit\":\"USD\",\"decimals\":6,\"sub\":\"8f14e45fceea167a5a36dedd4bea2543\",\"usage\":{\"day\":1243180,\"all_time\":61438050},\"jkts\":{\"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs\":38215600}}",
|
|
116
|
+
"signature_base": "\"@status\": 200\n\"content-type\": application/json\n\"content-digest\": sha-256=:Jj+8+vDgDSFJryQMnASY9O2TEznYDi1lsJ/+DRo7s14=:\n\"@authority\";req: api.example\n\"@path\";req: /usage\n\"@signature-params\": (\"@status\" \"content-type\" \"content-digest\" \"@authority\";req \"@path\";req);created=1756500001",
|
|
117
|
+
"response_headers": {
|
|
118
|
+
"Content-Digest": "sha-256=:Jj+8+vDgDSFJryQMnASY9O2TEznYDi1lsJ/+DRo7s14=:",
|
|
119
|
+
"Signature-Input": "sig=(\"@status\" \"content-type\" \"content-digest\" \"@authority\";req \"@path\";req);created=1756500001",
|
|
120
|
+
"Signature": "sig=:tPyxKUn/9Grz7cMH0k6RIKjoy78KuoqkrMUZsocLi1UtWxFgqyKo0yANnu4TBlZm6593A9BzgoaUrv7mgFtUCA==:",
|
|
121
|
+
"Signature-Key": "sig=jwks_uri; jwks_uri=\"https://api.example/jwks.json\""
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Generate the AAuth Budgets test vectors (vectors/*.json).
|
|
2
|
+
|
|
3
|
+
Deterministic by construction: fixed seeds, fixed timestamps. Re-run after any
|
|
4
|
+
serialization change; the test suite pins the same bytes.
|
|
5
|
+
|
|
6
|
+
.venv/bin/python vectors/generate.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import pathlib
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
|
18
|
+
|
|
19
|
+
from regent_httpsig import ResponseSigner, build_aauth_budget_header # noqa: E402
|
|
20
|
+
from regent_httpsig.jwk import b64url # noqa: E402
|
|
21
|
+
from regent_httpsig.sfv import build_aauth_requirement # noqa: E402
|
|
22
|
+
|
|
23
|
+
OUT = pathlib.Path(__file__).resolve().parent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def headers() -> dict:
|
|
27
|
+
"""AAuth-Budget / AAuth-Requirement serializations. The field is an
|
|
28
|
+
RFC 9651 Dictionary — members are COMMA-separated (the draft's prose);
|
|
29
|
+
parameters inside AAuth-Requirement members use semicolons."""
|
|
30
|
+
cases = [
|
|
31
|
+
{
|
|
32
|
+
"name": "success-with-cost",
|
|
33
|
+
"input": {"remaining": 1568800, "cost": 221200,
|
|
34
|
+
"unit": "USD", "decimals": 6},
|
|
35
|
+
"expected": build_aauth_budget_header(
|
|
36
|
+
remaining=1568800, cost=221200, unit="USD", decimals=6),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "refusal-insufficient-with-required",
|
|
40
|
+
"note": "`required` rides only on insufficient-budget refusals: "
|
|
41
|
+
"what THIS request needed, so the retry is a calculation.",
|
|
42
|
+
"input": {"remaining": 300, "required": 750,
|
|
43
|
+
"unit": "USD", "decimals": 6},
|
|
44
|
+
"expected": build_aauth_budget_header(
|
|
45
|
+
remaining=300, required=750, unit="USD", decimals=6),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "streaming-cost-omitted",
|
|
49
|
+
"note": "No-trailer runtime: cost omitted, reserved REQUIRED, "
|
|
50
|
+
"remaining already net of the hold. The agent recovers "
|
|
51
|
+
"cost = prev remaining + reserved - next remaining.",
|
|
52
|
+
"input": {"remaining": 1568800, "reserved": 431200,
|
|
53
|
+
"unit": "USD", "decimals": 6},
|
|
54
|
+
"expected": build_aauth_budget_header(
|
|
55
|
+
remaining=1568800, reserved=431200, unit="USD", decimals=6),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "exhaustion-zero",
|
|
59
|
+
"input": {"remaining": 0, "unit": "USD", "decimals": 6},
|
|
60
|
+
"expected": build_aauth_budget_header(
|
|
61
|
+
remaining=0, unit="USD", decimals=6),
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
requirement = [
|
|
65
|
+
{
|
|
66
|
+
"name": "refusal-with-resource-token",
|
|
67
|
+
"input": {"reason": "budget-exhausted",
|
|
68
|
+
"resource_token": "eyJexample"},
|
|
69
|
+
"expected": build_aauth_requirement(
|
|
70
|
+
reason="budget-exhausted", resource_token="eyJexample"),
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
return {"aauth_budget": cases, "aauth_requirement": requirement}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def budget_claim() -> dict:
|
|
77
|
+
"""The one budget-object shape, appearing unchanged in every position."""
|
|
78
|
+
return {
|
|
79
|
+
"shape": {"amount": 5000000, "unit": "USD", "decimals": 6},
|
|
80
|
+
"notes": [
|
|
81
|
+
"amount: non-negative integer, bounded by the 15-digit SF limit",
|
|
82
|
+
"decimals is NOT constrained to the ISO 4217 minor unit",
|
|
83
|
+
"all three members REQUIRED wherever `budget` appears",
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def consumption_records() -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"budget_consumed": [
|
|
91
|
+
{"jti": "at-2", "consumed": 431200},
|
|
92
|
+
{"jti": "at-1", "consumed": 221200},
|
|
93
|
+
],
|
|
94
|
+
"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",
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def signed_usage_response() -> dict:
|
|
104
|
+
"""A fully worked signed usage response with a FIXED key and timestamp —
|
|
105
|
+
verify it independently: Ed25519 over the base string below."""
|
|
106
|
+
seed = b64url(bytes(range(32)))
|
|
107
|
+
signer = ResponseSigner(seed=seed, jwks_url="https://api.example/jwks.json")
|
|
108
|
+
body_doc = {
|
|
109
|
+
"as_of": 1756500000,
|
|
110
|
+
"aud": "https://ps.example",
|
|
111
|
+
"unit": "USD",
|
|
112
|
+
"decimals": 6,
|
|
113
|
+
"sub": "8f14e45fceea167a5a36dedd4bea2543",
|
|
114
|
+
"usage": {"day": 1243180, "all_time": 61438050},
|
|
115
|
+
"jkts": {"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs": 38215600},
|
|
116
|
+
}
|
|
117
|
+
body = json.dumps(body_doc, separators=(",", ":")).encode()
|
|
118
|
+
headers = signer.sign(status=200, content_type="application/json",
|
|
119
|
+
body=body, authority="api.example", path="/usage",
|
|
120
|
+
created=1756500001)
|
|
121
|
+
digest = "sha-256=:" + base64.b64encode(
|
|
122
|
+
hashlib.sha256(body).digest()).decode() + ":"
|
|
123
|
+
base = "\n".join([
|
|
124
|
+
'"@status": 200',
|
|
125
|
+
'"content-type": application/json',
|
|
126
|
+
f'"content-digest": {digest}',
|
|
127
|
+
'"@authority";req: api.example',
|
|
128
|
+
'"@path";req: /usage',
|
|
129
|
+
'"@signature-params": ' + headers["Signature-Input"].split("=", 1)[1],
|
|
130
|
+
])
|
|
131
|
+
return {
|
|
132
|
+
"signing_key": {"seed_b64url": seed, "public_jwk": signer.public_jwk,
|
|
133
|
+
"keyid_rfc7638": signer.keyid},
|
|
134
|
+
"request_context": {"authority": "api.example", "path": "/usage"},
|
|
135
|
+
"body": body_doc,
|
|
136
|
+
"body_canonical_json": body.decode(),
|
|
137
|
+
"signature_base": base,
|
|
138
|
+
"response_headers": headers,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
vectors = {
|
|
144
|
+
"source": "regent-httpsig, first implementation of "
|
|
145
|
+
"draft-hardt-aauth-budgets (editor's copy, 2026-08-20)",
|
|
146
|
+
"headers": headers(),
|
|
147
|
+
"budget_claim": budget_claim(),
|
|
148
|
+
"consumption_records": consumption_records(),
|
|
149
|
+
"signed_usage_response": signed_usage_response(),
|
|
150
|
+
}
|
|
151
|
+
out = OUT / "aauth-budgets-vectors.json"
|
|
152
|
+
out.write_text(json.dumps(vectors, indent=2) + "\n")
|
|
153
|
+
print(f"wrote {out} ({out.stat().st_size} bytes)")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|