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.
- regent_httpsig/__init__.py +14 -0
- regent_httpsig/budget.py +158 -28
- regent_httpsig/fastapi.py +68 -11
- regent_httpsig/sfv.py +7 -1
- regent_httpsig/usage.py +223 -0
- {regent_httpsig-0.3.0.dist-info → regent_httpsig-0.5.0.dist-info}/METADATA +41 -3
- regent_httpsig-0.5.0.dist-info/RECORD +16 -0
- regent_httpsig-0.3.0.dist-info/RECORD +0 -15
- {regent_httpsig-0.3.0.dist-info → regent_httpsig-0.5.0.dist-info}/WHEEL +0 -0
- {regent_httpsig-0.3.0.dist-info → regent_httpsig-0.5.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.3.0.dist-info → regent_httpsig-0.5.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
231
|
-
|
|
232
|
-
|
|
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, "
|
|
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,
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
272
|
-
|
|
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,
|
|
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}")
|
regent_httpsig/usage.py
ADDED
|
@@ -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
|
+
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
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|