regent-httpsig 0.2.0__py3-none-any.whl → 0.4.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 +34 -2
- regent_httpsig/budget.py +345 -0
- regent_httpsig/config.py +7 -0
- regent_httpsig/fastapi.py +266 -2
- regent_httpsig/sfv.py +56 -0
- regent_httpsig/usage.py +223 -0
- regent_httpsig/verify.py +42 -17
- {regent_httpsig-0.2.0.dist-info → regent_httpsig-0.4.0.dist-info}/METADATA +67 -1
- regent_httpsig-0.4.0.dist-info/RECORD +16 -0
- regent_httpsig-0.2.0.dist-info/RECORD +0 -14
- {regent_httpsig-0.2.0.dist-info → regent_httpsig-0.4.0.dist-info}/WHEEL +0 -0
- {regent_httpsig-0.2.0.dist-info → regent_httpsig-0.4.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.2.0.dist-info → regent_httpsig-0.4.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -4,26 +4,58 @@ Web Bot Auth (what OpenAI ships, what Cloudflare/AWS/Google verify) and AAuth
|
|
|
4
4
|
(draft-hardt), in plain Python. See https://github.com/regent-protocol/regent-httpsig
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from regent_httpsig.budget import (
|
|
8
|
+
BudgetClaim,
|
|
9
|
+
InMemoryMeter,
|
|
10
|
+
InsufficientBudget,
|
|
11
|
+
InvalidBudgetClaim,
|
|
12
|
+
UnitMismatch,
|
|
13
|
+
)
|
|
7
14
|
from regent_httpsig.config import HttpsigConfig
|
|
8
15
|
from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
|
|
9
16
|
from regent_httpsig.netguard import NotPublicURL, assert_public_url
|
|
10
|
-
from regent_httpsig.
|
|
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
|
+
)
|
|
25
|
+
from regent_httpsig.sfv import (
|
|
26
|
+
build_aauth_budget_header,
|
|
27
|
+
build_aauth_requirement,
|
|
28
|
+
parse_signature_agent,
|
|
29
|
+
)
|
|
11
30
|
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
12
31
|
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
|
|
13
32
|
|
|
14
|
-
__version__ = "0.
|
|
33
|
+
__version__ = "0.3.0"
|
|
15
34
|
|
|
16
35
|
__all__ = [
|
|
17
36
|
"DIRECTORY_MEDIA_TYPE",
|
|
37
|
+
"BudgetClaim",
|
|
18
38
|
"EgressSigner",
|
|
19
39
|
"HttpsigConfig",
|
|
20
40
|
"HttpsigVerifier",
|
|
41
|
+
"InMemoryMeter",
|
|
42
|
+
"InsufficientBudget",
|
|
43
|
+
"InvalidBudgetClaim",
|
|
21
44
|
"NotPublicURL",
|
|
45
|
+
"UnitMismatch",
|
|
22
46
|
"VerifiedSignature",
|
|
23
47
|
"WBA_TAG",
|
|
24
48
|
"__version__",
|
|
25
49
|
"assert_public_url",
|
|
26
50
|
"b64url",
|
|
51
|
+
"ResponseSigner",
|
|
52
|
+
"UsageQueryError",
|
|
53
|
+
"build_usage_response",
|
|
54
|
+
"make_usage_endpoint",
|
|
55
|
+
"parse_usage_request",
|
|
56
|
+
"validate_budget_grant",
|
|
57
|
+
"build_aauth_budget_header",
|
|
58
|
+
"build_aauth_requirement",
|
|
27
59
|
"generate_seed",
|
|
28
60
|
"jwk_thumbprint",
|
|
29
61
|
"load_ed25519_jwk",
|
regent_httpsig/budget.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""AAuth Budgets (draft-hardt-aauth-budgets, editor's copy) — resource-side core.
|
|
2
|
+
|
|
3
|
+
The auth token carries a spending envelope::
|
|
4
|
+
|
|
5
|
+
"budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
|
|
6
|
+
|
|
7
|
+
and the resource meters every request against it: reserve the request's maximum
|
|
8
|
+
cost atomically, serve, commit the actual cost, release the difference. The
|
|
9
|
+
draft requires consumption to be aggregated atomically across all live auth
|
|
10
|
+
tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
|
|
11
|
+
of a principal's live tokens and counts reservations + consumption against
|
|
12
|
+
that pool.
|
|
13
|
+
|
|
14
|
+
Everything here is framework-free; the FastAPI glue lives in
|
|
15
|
+
:mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import itertools
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BudgetClaim",
|
|
30
|
+
"InMemoryMeter",
|
|
31
|
+
"InsufficientBudget",
|
|
32
|
+
"InvalidBudgetClaim",
|
|
33
|
+
"Reservation",
|
|
34
|
+
"UnitMismatch",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
MeterKey = tuple[str, str, str] # (iss, sub, aud) — the draft's aggregation key
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidBudgetClaim(ValueError):
|
|
41
|
+
"""A ``budget`` member is present but malformed (issuer bug — not spendable)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnitMismatch(ValueError):
|
|
45
|
+
"""A grant's unit/decimals differ from the pool's — one envelope, one unit."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class BudgetClaim:
|
|
50
|
+
"""The ``budget`` claim: integer amount in ``unit`` scaled by ``decimals``.
|
|
51
|
+
|
|
52
|
+
``amount=5000000, unit="USD", decimals=6`` is $5.00 — all arithmetic stays
|
|
53
|
+
in integers; the scale only matters at display time.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
amount: int
|
|
57
|
+
unit: str
|
|
58
|
+
decimals: int
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
|
|
62
|
+
"""Extract the claim from a token's claim set. ``None`` when absent;
|
|
63
|
+
:class:`InvalidBudgetClaim` when present but malformed (all three
|
|
64
|
+
members are REQUIRED, integers must be non-negative, bools are not
|
|
65
|
+
integers here)."""
|
|
66
|
+
raw = claims.get("budget")
|
|
67
|
+
if raw is None:
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(raw, Mapping):
|
|
70
|
+
raise InvalidBudgetClaim("budget claim must be an object")
|
|
71
|
+
amount, unit, decimals = raw.get("amount"), raw.get("unit"), raw.get("decimals")
|
|
72
|
+
if (
|
|
73
|
+
isinstance(amount, bool) or not isinstance(amount, int) or amount < 0
|
|
74
|
+
or not isinstance(unit, str) or not unit
|
|
75
|
+
or isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0
|
|
76
|
+
):
|
|
77
|
+
raise InvalidBudgetClaim("budget claim requires amount/unit/decimals")
|
|
78
|
+
return BudgetClaim(amount=amount, unit=unit, decimals=decimals)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Reservation:
|
|
83
|
+
"""An atomic hold on the pool for one in-flight request. Never revised —
|
|
84
|
+
committed (with the actual cost) or released, exactly once."""
|
|
85
|
+
|
|
86
|
+
rid: int
|
|
87
|
+
key: MeterKey
|
|
88
|
+
jti: str
|
|
89
|
+
amount: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class InsufficientBudget:
|
|
94
|
+
"""Refusal: the request's maximum cost exceeds the pool's remaining balance.
|
|
95
|
+
``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
|
|
96
|
+
(``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
|
|
97
|
+
|
|
98
|
+
remaining: int
|
|
99
|
+
exhausted: bool
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class _Pool:
|
|
104
|
+
unit: str
|
|
105
|
+
decimals: int
|
|
106
|
+
grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
|
|
107
|
+
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
|
|
108
|
+
jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
|
|
109
|
+
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
|
|
110
|
+
last_activity: float = 0.0
|
|
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
|
+
|
|
155
|
+
class InMemoryMeter:
|
|
156
|
+
"""Single-process meter (asyncio-safe). Right for a single-instance service;
|
|
157
|
+
multi-replica deployments need a shared backend behind the same interface.
|
|
158
|
+
|
|
159
|
+
Crash-safety is conservative: a reservation not committed or released within
|
|
160
|
+
``reservation_ttl`` seconds is treated as fully consumed — the owner's
|
|
161
|
+
envelope is never silently under-counted by a crashed handler.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, *, reservation_ttl: float = 120.0,
|
|
165
|
+
retention_seconds: float = 7200.0,
|
|
166
|
+
usage_key_retention: float = 86400.0) -> None:
|
|
167
|
+
self._pools: dict[MeterKey, _Pool] = {}
|
|
168
|
+
self._lock = asyncio.Lock()
|
|
169
|
+
self._rids = itertools.count(1)
|
|
170
|
+
self._reservation_ttl = reservation_ttl
|
|
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
|
|
182
|
+
|
|
183
|
+
# ── internals (call under lock) ──────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def _purge(self, key: MeterKey, now: float) -> _Pool | None:
|
|
186
|
+
pool = self._pools.get(key)
|
|
187
|
+
if pool is None:
|
|
188
|
+
return None
|
|
189
|
+
# Expired, unresolved reservations count as consumed (conservative).
|
|
190
|
+
for rid, (jti, amount, deadline) in list(pool.reservations.items()):
|
|
191
|
+
if deadline <= now:
|
|
192
|
+
pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
|
|
193
|
+
del pool.reservations[rid]
|
|
194
|
+
self._record_usage(key, pool, jti, amount)
|
|
195
|
+
# Expired grants leave the pool; their consumption records remain for
|
|
196
|
+
# budget_consumed reporting until the retention window passes.
|
|
197
|
+
for jti, (_, exp) in list(pool.grants.items()):
|
|
198
|
+
if exp <= now:
|
|
199
|
+
del pool.grants[jti]
|
|
200
|
+
if (not pool.grants and not pool.reservations
|
|
201
|
+
and now - pool.last_activity > self._retention):
|
|
202
|
+
del self._pools[key]
|
|
203
|
+
return None
|
|
204
|
+
return pool
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _remaining(pool: _Pool) -> int:
|
|
208
|
+
live = sum(a for a, _ in pool.grants.values())
|
|
209
|
+
spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
|
|
210
|
+
held = sum(a for _, a, _ in pool.reservations.values())
|
|
211
|
+
return max(0, live - spent - held)
|
|
212
|
+
|
|
213
|
+
# ── public interface (the BudgetMeter contract) ──────────────────────────
|
|
214
|
+
|
|
215
|
+
async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
|
|
216
|
+
exp: float, jkt: str = "") -> None:
|
|
217
|
+
"""Register a token's envelope in the principal's pool (idempotent per
|
|
218
|
+
``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
|
|
219
|
+
recorded so consumption records can be scoped to the presenting agent
|
|
220
|
+
(one agent must not learn about its siblings). Raises
|
|
221
|
+
:class:`UnitMismatch` if the pool already runs in a different unit —
|
|
222
|
+
one envelope, one unit, no FX at the meter."""
|
|
223
|
+
async with self._lock:
|
|
224
|
+
now = time.monotonic()
|
|
225
|
+
wall_delta = exp - time.time()
|
|
226
|
+
pool = self._purge(key, now)
|
|
227
|
+
if pool is None:
|
|
228
|
+
pool = self._pools.setdefault(
|
|
229
|
+
key, _Pool(unit=claim.unit, decimals=claim.decimals))
|
|
230
|
+
if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
|
|
231
|
+
raise UnitMismatch(
|
|
232
|
+
f"pool runs in {pool.unit}/{pool.decimals}, "
|
|
233
|
+
f"grant is {claim.unit}/{claim.decimals}")
|
|
234
|
+
pool.last_activity = now
|
|
235
|
+
if jkt:
|
|
236
|
+
pool.jkt_of.setdefault(jti, jkt)
|
|
237
|
+
if jti not in pool.grants and wall_delta > 0:
|
|
238
|
+
pool.grants[jti] = (claim.amount, now + wall_delta)
|
|
239
|
+
|
|
240
|
+
async def reserve(self, key: MeterKey, jti: str,
|
|
241
|
+
max_cost: int) -> Reservation | InsufficientBudget:
|
|
242
|
+
async with self._lock:
|
|
243
|
+
now = time.monotonic()
|
|
244
|
+
pool = self._purge(key, now)
|
|
245
|
+
if pool is None or jti not in pool.grants:
|
|
246
|
+
return InsufficientBudget(remaining=0, exhausted=True)
|
|
247
|
+
remaining = self._remaining(pool)
|
|
248
|
+
if max_cost > remaining:
|
|
249
|
+
return InsufficientBudget(remaining=remaining,
|
|
250
|
+
exhausted=remaining == 0)
|
|
251
|
+
rid = next(self._rids)
|
|
252
|
+
pool.reservations[rid] = (jti, max_cost, now + self._reservation_ttl)
|
|
253
|
+
pool.last_activity = now
|
|
254
|
+
return Reservation(rid=rid, key=key, jti=jti, amount=max_cost)
|
|
255
|
+
|
|
256
|
+
async def commit(self, res: Reservation, actual: int) -> int:
|
|
257
|
+
"""Commit the actual cost (clamped to the reserved amount — reservations
|
|
258
|
+
are never revised upward) and return the pool's remaining balance."""
|
|
259
|
+
async with self._lock:
|
|
260
|
+
now = time.monotonic()
|
|
261
|
+
pool = self._purge(res.key, now)
|
|
262
|
+
if pool is None:
|
|
263
|
+
return 0
|
|
264
|
+
held = pool.reservations.pop(res.rid, None)
|
|
265
|
+
cost = min(max(actual, 0), held[1] if held else res.amount)
|
|
266
|
+
pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
|
|
267
|
+
pool.last_activity = now
|
|
268
|
+
if cost > 0:
|
|
269
|
+
self._record_usage(res.key, pool, res.jti, cost)
|
|
270
|
+
return self._remaining(pool)
|
|
271
|
+
|
|
272
|
+
async def release(self, res: Reservation) -> int:
|
|
273
|
+
async with self._lock:
|
|
274
|
+
pool = self._purge(res.key, time.monotonic())
|
|
275
|
+
if pool is None:
|
|
276
|
+
return 0
|
|
277
|
+
pool.reservations.pop(res.rid, None)
|
|
278
|
+
return self._remaining(pool)
|
|
279
|
+
|
|
280
|
+
async def remaining(self, key: MeterKey) -> int:
|
|
281
|
+
async with self._lock:
|
|
282
|
+
pool = self._purge(key, time.monotonic())
|
|
283
|
+
return 0 if pool is None else self._remaining(pool)
|
|
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
|
+
|
|
325
|
+
async def consumed_records(self, key: MeterKey,
|
|
326
|
+
jkt: str | None = None) -> list[dict[str, Any]]:
|
|
327
|
+
"""Per-token consumption for the resource token's ``budget_consumed``
|
|
328
|
+
claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
|
|
329
|
+
PS deduplicates by ``jti``, so reporting the same record twice is safe.
|
|
330
|
+
|
|
331
|
+
When ``jkt`` is given, records are scoped to tokens bound to that key:
|
|
332
|
+
the agent carrying the resource token sees only its OWN spending, never
|
|
333
|
+
its siblings' (privacy between a principal's agents, and no extra
|
|
334
|
+
figures to infer the ceiling from). Consequence: an abandoned agent's
|
|
335
|
+
records are never carried home by siblings — the PS-side conservative
|
|
336
|
+
rule (unreported expired allocation = fully consumed) is the backstop."""
|
|
337
|
+
async with self._lock:
|
|
338
|
+
pool = self._purge(key, time.monotonic())
|
|
339
|
+
if pool is None:
|
|
340
|
+
return []
|
|
341
|
+
return [
|
|
342
|
+
{"jti": jti, "consumed": total}
|
|
343
|
+
for jti, total in sorted(pool.consumed.items())
|
|
344
|
+
if total > 0 and (jkt is None or pool.jkt_of.get(jti) == jkt)
|
|
345
|
+
]
|
regent_httpsig/config.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Mapping
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
|
|
7
8
|
__all__ = ["HttpsigConfig"]
|
|
@@ -38,3 +39,9 @@ class HttpsigConfig:
|
|
|
38
39
|
# AAuth person tokens — their `aud` must name this resource. None disables
|
|
39
40
|
# the person-token path entirely.
|
|
40
41
|
resource_url: str | None = None
|
|
42
|
+
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes):
|
|
43
|
+
# issuer → JWKS URL for each Person Server this resource accepts auth tokens
|
|
44
|
+
# from. A resource has an established relationship with its PS, so the key
|
|
45
|
+
# location is pinned by configuration rather than discovered open-world.
|
|
46
|
+
# Empty (the default) disables the auth-token path entirely.
|
|
47
|
+
trusted_ps: Mapping[str, str] = field(default_factory=dict)
|
regent_httpsig/fastapi.py
CHANGED
|
@@ -25,13 +25,37 @@ $scheme;``), or verification will fail on the scheme mismatch.
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
import inspect
|
|
29
|
+
import logging
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
from typing import Any
|
|
29
32
|
|
|
33
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
|
34
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
35
|
+
from starlette.responses import JSONResponse
|
|
36
|
+
|
|
37
|
+
from regent_httpsig.budget import (
|
|
38
|
+
BudgetClaim,
|
|
39
|
+
InMemoryMeter,
|
|
40
|
+
InsufficientBudget,
|
|
41
|
+
InvalidBudgetClaim,
|
|
42
|
+
MeterKey,
|
|
43
|
+
Reservation,
|
|
44
|
+
UnitMismatch,
|
|
45
|
+
)
|
|
46
|
+
from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
|
|
30
47
|
from regent_httpsig.verify import HttpsigVerifier, VerifiedSignature
|
|
31
48
|
|
|
32
|
-
__all__ = [
|
|
49
|
+
__all__ = [
|
|
50
|
+
"BudgetMiddleware",
|
|
51
|
+
"RequiredSignatureDep",
|
|
52
|
+
"SignatureDep",
|
|
53
|
+
"VerifiedSignature",
|
|
54
|
+
"attach",
|
|
55
|
+
]
|
|
33
56
|
|
|
34
57
|
_STATE_ATTR = "regent_httpsig_verifier"
|
|
58
|
+
logger = logging.getLogger("regent_httpsig")
|
|
35
59
|
|
|
36
60
|
|
|
37
61
|
def attach(app: FastAPI, verifier: HttpsigVerifier) -> None:
|
|
@@ -92,3 +116,243 @@ async def require_signature(request: Request) -> VerifiedSignature:
|
|
|
92
116
|
|
|
93
117
|
SignatureDep = Depends(get_signature)
|
|
94
118
|
RequiredSignatureDep = Depends(require_signature)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
|
|
122
|
+
|
|
123
|
+
PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
|
|
124
|
+
ResourceTokenProvider = Callable[
|
|
125
|
+
[MeterKey, "list[dict[str, Any]]"], "str | None | Awaitable[str | None]"
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _maybe_await(value: Any) -> Any:
|
|
130
|
+
return await value if inspect.isawaitable(value) else value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class BudgetMiddleware(BaseHTTPMiddleware):
|
|
134
|
+
"""Meter budget-carrying requests: reserve the maximum cost atomically,
|
|
135
|
+
serve, commit the actual, release the difference, and answer with an
|
|
136
|
+
``AAuth-Budget`` header. The full checklist a resource owes the draft —
|
|
137
|
+
pricing excepted, which is the one thing only the resource can know.
|
|
138
|
+
|
|
139
|
+
Usage::
|
|
140
|
+
|
|
141
|
+
meter = InMemoryMeter()
|
|
142
|
+
app.add_middleware(
|
|
143
|
+
BudgetMiddleware,
|
|
144
|
+
verifier=HttpsigVerifier(HttpsigConfig(
|
|
145
|
+
resource_url="https://api.example",
|
|
146
|
+
trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
|
|
147
|
+
)),
|
|
148
|
+
meter=meter,
|
|
149
|
+
price_fn=lambda request: PRICES.get(request.url.path),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
- ``price_fn(request)`` returns the request's MAXIMUM cost in the envelope's
|
|
153
|
+
minor units, or ``None`` for routes outside budget enforcement.
|
|
154
|
+
- A handler that knows the actual cost sets ``request.state.budget_cost``
|
|
155
|
+
before returning; otherwise the full reservation is committed.
|
|
156
|
+
- ``require=False`` (default) lets requests without a budget envelope pass
|
|
157
|
+
through untouched — run per-decision authorization for them instead.
|
|
158
|
+
``require=True`` refuses them with 401 + ``AAuth-Requirement``.
|
|
159
|
+
- Error responses (4xx/5xx) release the reservation — nothing was served,
|
|
160
|
+
the envelope is not charged.
|
|
161
|
+
- ``resource_token_provider(key, consumed_records)`` (optional) mints the
|
|
162
|
+
resource token embedded in budget-refusal responses so the agent can
|
|
163
|
+
carry ``budget_consumed`` back to its PS for re-authorization.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
app: Any,
|
|
169
|
+
*,
|
|
170
|
+
verifier: HttpsigVerifier,
|
|
171
|
+
meter: InMemoryMeter | Any = None,
|
|
172
|
+
price_fn: PriceFn,
|
|
173
|
+
require: bool = False,
|
|
174
|
+
resource_token_provider: ResourceTokenProvider | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__(app)
|
|
177
|
+
self._verifier = verifier
|
|
178
|
+
self._meter = meter if meter is not None else InMemoryMeter()
|
|
179
|
+
self._price_fn = price_fn
|
|
180
|
+
self._require = require
|
|
181
|
+
self._resource_token = resource_token_provider
|
|
182
|
+
|
|
183
|
+
async def dispatch(
|
|
184
|
+
self, request: Request, call_next: RequestResponseEndpoint
|
|
185
|
+
) -> Response:
|
|
186
|
+
max_cost = await _maybe_await(self._price_fn(request))
|
|
187
|
+
if max_cost is None:
|
|
188
|
+
return await call_next(request)
|
|
189
|
+
|
|
190
|
+
sig = await self._verified(request)
|
|
191
|
+
envelope: BudgetClaim | None = None
|
|
192
|
+
if sig is not None:
|
|
193
|
+
try:
|
|
194
|
+
envelope = BudgetClaim.parse(sig.claims)
|
|
195
|
+
except InvalidBudgetClaim as exc:
|
|
196
|
+
logger.warning("malformed budget claim from %s: %s", sig.agent, exc)
|
|
197
|
+
jti = str(sig.claims.get("jti") or "") if sig else ""
|
|
198
|
+
|
|
199
|
+
if sig is None or envelope is None or not jti:
|
|
200
|
+
if self._require:
|
|
201
|
+
return self._refusal(reason=None, envelope=None, remaining=None)
|
|
202
|
+
return await call_next(request) # per-decision path handles it
|
|
203
|
+
|
|
204
|
+
key: MeterKey = (
|
|
205
|
+
str(sig.claims.get("iss", "")),
|
|
206
|
+
str(sig.claims.get("sub", "")),
|
|
207
|
+
str(sig.claims.get("aud", "")),
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
# sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
|
|
211
|
+
# recorded so refusal-time consumption records are scoped to the
|
|
212
|
+
# presenting agent and never disclose its siblings' spending.
|
|
213
|
+
await self._meter.observe_grant(key, jti, envelope,
|
|
214
|
+
float(sig.claims.get("exp", 0)),
|
|
215
|
+
jkt=sig.keyid)
|
|
216
|
+
except UnitMismatch as exc:
|
|
217
|
+
logger.warning("budget unit mismatch for %s: %s", key, exc)
|
|
218
|
+
return self._refusal(reason="insufficient-budget", envelope=envelope,
|
|
219
|
+
remaining=0, key=key)
|
|
220
|
+
|
|
221
|
+
outcome = await self._meter.reserve(key, jti, int(max_cost))
|
|
222
|
+
if isinstance(outcome, InsufficientBudget):
|
|
223
|
+
reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
|
|
224
|
+
return await self._refusal_with_token(
|
|
225
|
+
reason=reason, envelope=envelope, remaining=outcome.remaining,
|
|
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,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
reservation: Reservation = outcome
|
|
233
|
+
try:
|
|
234
|
+
response = await call_next(request)
|
|
235
|
+
except Exception:
|
|
236
|
+
await self._meter.release(reservation)
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
if response.status_code >= 400:
|
|
240
|
+
# Nothing was served — the envelope is not charged for errors.
|
|
241
|
+
remaining = await self._meter.release(reservation)
|
|
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
|
|
258
|
+
else:
|
|
259
|
+
actual = getattr(request.state, "budget_cost", None)
|
|
260
|
+
cost = int(actual) if actual is not None else int(max_cost)
|
|
261
|
+
remaining = await self._meter.commit(reservation, cost)
|
|
262
|
+
response.headers["AAuth-Budget"] = build_aauth_budget_header(
|
|
263
|
+
remaining=remaining, cost=cost,
|
|
264
|
+
unit=envelope.unit, decimals=envelope.decimals,
|
|
265
|
+
)
|
|
266
|
+
return response
|
|
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
|
+
|
|
298
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
async def _verified(self, request: Request) -> VerifiedSignature | None:
|
|
301
|
+
cached = getattr(request.state, "regent_httpsig_result", "unset")
|
|
302
|
+
if cached != "unset":
|
|
303
|
+
return cached # type: ignore[return-value]
|
|
304
|
+
result = None
|
|
305
|
+
if "signature" in request.headers:
|
|
306
|
+
result = await self._verifier.verify(
|
|
307
|
+
request.method, _public_url(request), dict(request.headers)
|
|
308
|
+
)
|
|
309
|
+
request.state.regent_httpsig_result = result
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
async def _refusal_with_token(
|
|
313
|
+
self, *, reason: str, envelope: BudgetClaim,
|
|
314
|
+
remaining: int, key: MeterKey, jkt: str | None = None,
|
|
315
|
+
required: int | None = None,
|
|
316
|
+
) -> Response:
|
|
317
|
+
token: str | None = None
|
|
318
|
+
if self._resource_token is not None:
|
|
319
|
+
try:
|
|
320
|
+
records = await self._meter.consumed_records(key, jkt=jkt)
|
|
321
|
+
token = await _maybe_await(self._resource_token(key, records))
|
|
322
|
+
except Exception: # noqa: BLE001 — refusal must not fail on the extras
|
|
323
|
+
logger.warning("resource_token_provider failed", exc_info=True)
|
|
324
|
+
return self._refusal(reason=reason, envelope=envelope,
|
|
325
|
+
remaining=remaining, resource_token=token,
|
|
326
|
+
required=required)
|
|
327
|
+
|
|
328
|
+
def _refusal(
|
|
329
|
+
self, *, reason: str | None, envelope: BudgetClaim | None,
|
|
330
|
+
remaining: int | None, key: MeterKey | None = None,
|
|
331
|
+
resource_token: str | None = None, required: int | None = None,
|
|
332
|
+
) -> Response:
|
|
333
|
+
headers = {
|
|
334
|
+
"AAuth-Requirement": build_aauth_requirement(
|
|
335
|
+
reason=reason or "insufficient-budget",
|
|
336
|
+
resource_token=resource_token,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
if remaining is not None and envelope is not None:
|
|
340
|
+
headers["AAuth-Budget"] = build_aauth_budget_header(
|
|
341
|
+
remaining=remaining, required=required,
|
|
342
|
+
unit=envelope.unit, decimals=envelope.decimals,
|
|
343
|
+
)
|
|
344
|
+
code = "AUTH_TOKEN_REQUIRED" if reason is None else reason.upper().replace("-", "_")
|
|
345
|
+
return JSONResponse(
|
|
346
|
+
status_code=401,
|
|
347
|
+
content={
|
|
348
|
+
"code": code,
|
|
349
|
+
"message": (
|
|
350
|
+
"Present an auth token with a budget envelope "
|
|
351
|
+
"(AAuth Budgets) to call this endpoint."
|
|
352
|
+
if reason is None else
|
|
353
|
+
"The request's maximum cost exceeds the envelope's remaining "
|
|
354
|
+
"balance. Re-authorize with your PS for a fresh auth token."
|
|
355
|
+
),
|
|
356
|
+
},
|
|
357
|
+
headers=headers,
|
|
358
|
+
)
|
regent_httpsig/sfv.py
CHANGED
|
@@ -32,6 +32,8 @@ __all__ = [
|
|
|
32
32
|
"SFDictionary",
|
|
33
33
|
"SFItem",
|
|
34
34
|
"StaticKeyResolver",
|
|
35
|
+
"build_aauth_budget_header",
|
|
36
|
+
"build_aauth_requirement",
|
|
35
37
|
"parse_signature_agent",
|
|
36
38
|
"parse_signature_key_header",
|
|
37
39
|
]
|
|
@@ -130,3 +132,57 @@ def parse_signature_key_header(value: str) -> tuple[str, str] | None:
|
|
|
130
132
|
except Exception: # noqa: BLE001
|
|
131
133
|
return None
|
|
132
134
|
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── AAuth Budgets response headers (draft-hardt-aauth-budgets) ───────────────
|
|
138
|
+
# Hand-serialized: the value space is tiny (non-negative sf-integers, one
|
|
139
|
+
# sf-string, sf-tokens) and the golden tests round-trip the output through the
|
|
140
|
+
# real http_sfv parser. NOTE: the field is declared an RFC 9651 *Dictionary*,
|
|
141
|
+
# so members are comma-separated — the draft's §11 example shows semicolons,
|
|
142
|
+
# which is the *parameter* separator; flagged for the implementation report.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _sf_string(value: str) -> str:
|
|
146
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_aauth_budget_header(
|
|
150
|
+
*,
|
|
151
|
+
remaining: int,
|
|
152
|
+
cost: int | None = None,
|
|
153
|
+
reserved: int | None = None,
|
|
154
|
+
required: int | None = None,
|
|
155
|
+
unit: str | None = None,
|
|
156
|
+
decimals: int | None = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Serialize the ``AAuth-Budget`` response header. ``remaining`` is the only
|
|
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."""
|
|
163
|
+
if (unit is None) != (decimals is None):
|
|
164
|
+
raise ValueError("unit and decimals must be provided together")
|
|
165
|
+
members: list[str] = []
|
|
166
|
+
if cost is not None:
|
|
167
|
+
members.append(f"cost={cost}")
|
|
168
|
+
members.append(f"remaining={remaining}")
|
|
169
|
+
if reserved is not None:
|
|
170
|
+
members.append(f"reserved={reserved}")
|
|
171
|
+
if required is not None:
|
|
172
|
+
members.append(f"required={required}")
|
|
173
|
+
if unit is not None and decimals is not None:
|
|
174
|
+
members.append(f"unit={_sf_string(unit)}")
|
|
175
|
+
members.append(f"decimals={decimals}")
|
|
176
|
+
return ", ".join(members)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_aauth_requirement(*, reason: str, resource_token: str | None = None) -> str:
|
|
180
|
+
"""Serialize ``AAuth-Requirement`` for a budget refusal:
|
|
181
|
+
``requirement=auth-token;resource-token="eyJ…";reason=insufficient-budget``.
|
|
182
|
+
``reason`` is an sf-token (``insufficient-budget`` | ``budget-exhausted``);
|
|
183
|
+
the resource token (when the resource issues one) rides as an sf-string."""
|
|
184
|
+
out = "requirement=auth-token"
|
|
185
|
+
if resource_token:
|
|
186
|
+
out += f";resource-token={_sf_string(resource_token)}"
|
|
187
|
+
out += f";reason={reason}"
|
|
188
|
+
return out
|
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")
|
regent_httpsig/verify.py
CHANGED
|
@@ -58,7 +58,8 @@ AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
|
|
|
58
58
|
AAUTH_PERSON_METADATA_PATH = "/.well-known/aauth-person.json"
|
|
59
59
|
AAUTH_JWT_TYP = "aa-agent+jwt"
|
|
60
60
|
AAUTH_PERSON_TYP = "aa-person+jwt"
|
|
61
|
-
|
|
61
|
+
AAUTH_AUTH_TYP = "aa-auth+jwt" # PS-issued auth tokens — the budget carrier
|
|
62
|
+
# -11: person and auth tokens live at most one hour — enforced with tolerance.
|
|
62
63
|
PERSON_TOKEN_MAX_LIFETIME = 3600 + 90
|
|
63
64
|
|
|
64
65
|
|
|
@@ -311,9 +312,11 @@ class HttpsigVerifier:
|
|
|
311
312
|
if header.get("alg") in (None, "none"):
|
|
312
313
|
return None
|
|
313
314
|
|
|
314
|
-
# -11 token-type dispatch: agent tokens (identity mode)
|
|
315
|
-
# (PS-issued, per-resource, opt-in via config.resource_url)
|
|
315
|
+
# -11 token-type dispatch: agent tokens (identity mode), person tokens
|
|
316
|
+
# (PS-issued, per-resource, opt-in via config.resource_url) and auth
|
|
317
|
+
# tokens (PS-issued budget carriers, opt-in via config.trusted_ps).
|
|
316
318
|
typ = header.get("typ")
|
|
319
|
+
jwks_override: str | None = None
|
|
317
320
|
if typ == AAUTH_JWT_TYP:
|
|
318
321
|
scheme, expected_dwk = "aauth", "aauth-agent.json"
|
|
319
322
|
metadata_path, audience = AAUTH_METADATA_PATH, None
|
|
@@ -324,15 +327,32 @@ class HttpsigVerifier:
|
|
|
324
327
|
return None
|
|
325
328
|
scheme, expected_dwk = "aauth-person", "aauth-person.json"
|
|
326
329
|
metadata_path, audience = AAUTH_PERSON_METADATA_PATH, self.config.resource_url
|
|
330
|
+
elif typ == AAUTH_AUTH_TYP:
|
|
331
|
+
if not self.config.resource_url or not self.config.trusted_ps:
|
|
332
|
+
logger.info("auth token presented but resource_url/trusted_ps is not "
|
|
333
|
+
"configured — auth-token verification is disabled")
|
|
334
|
+
return None
|
|
335
|
+
scheme, expected_dwk = "aauth-auth", None
|
|
336
|
+
metadata_path, audience = None, self.config.resource_url
|
|
327
337
|
else:
|
|
328
338
|
return None
|
|
329
339
|
|
|
330
340
|
iss = str(unverified.get("iss", ""))
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
341
|
+
if typ == AAUTH_AUTH_TYP:
|
|
342
|
+
# The resource pins its PS: issuer must be explicitly trusted and its
|
|
343
|
+
# JWKS location comes from configuration, not open-world discovery.
|
|
344
|
+
override = self.config.trusted_ps.get(iss)
|
|
345
|
+
if override is None:
|
|
346
|
+
logger.info("auth token issuer %s is not a configured PS", iss[:100])
|
|
347
|
+
return None
|
|
348
|
+
jwks_override = override
|
|
349
|
+
else:
|
|
350
|
+
bad_iss = (unverified.get("dwk") != expected_dwk
|
|
351
|
+
or not iss.startswith("https://"))
|
|
352
|
+
if bad_iss and not (
|
|
353
|
+
iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
|
|
354
|
+
):
|
|
355
|
+
return None
|
|
336
356
|
|
|
337
357
|
# AAuth -11 / RFC 9864: fully-specified algorithms. "EdDSA" (polymorphic)
|
|
338
358
|
# is accepted only while require_fully_specified_algs is False — a
|
|
@@ -342,10 +362,13 @@ class HttpsigVerifier:
|
|
|
342
362
|
allowed_algs.append("EdDSA")
|
|
343
363
|
|
|
344
364
|
# 1) Verify the token against the issuer's published JWKS.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
365
|
+
if jwks_override is not None:
|
|
366
|
+
jwks = await self._fetch_json(jwks_override)
|
|
367
|
+
else:
|
|
368
|
+
metadata = await self._fetch_json(iss.rstrip("/") + str(metadata_path))
|
|
369
|
+
if not metadata or not metadata.get("jwks_uri"):
|
|
370
|
+
return None
|
|
371
|
+
jwks = await self._fetch_json(str(metadata["jwks_uri"]))
|
|
349
372
|
if not jwks:
|
|
350
373
|
return None
|
|
351
374
|
issuer_key = None
|
|
@@ -379,11 +402,11 @@ class HttpsigVerifier:
|
|
|
379
402
|
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
|
|
380
403
|
return None
|
|
381
404
|
|
|
382
|
-
# -11:
|
|
383
|
-
if typ
|
|
405
|
+
# -11: person and auth tokens live at most one hour.
|
|
406
|
+
if typ in (AAUTH_PERSON_TYP, AAUTH_AUTH_TYP):
|
|
384
407
|
lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
|
|
385
408
|
if lifetime <= 0 or lifetime > PERSON_TOKEN_MAX_LIFETIME:
|
|
386
|
-
logger.info("
|
|
409
|
+
logger.info("%s lifetime %ss out of bounds iss=%s", typ, lifetime, iss)
|
|
387
410
|
return None
|
|
388
411
|
|
|
389
412
|
# 2) Proof of possession: the request signature must verify against cnf.jwk.
|
|
@@ -423,12 +446,14 @@ class HttpsigVerifier:
|
|
|
423
446
|
scheme=scheme,
|
|
424
447
|
agent=iss,
|
|
425
448
|
keyid=jwk_thumbprint(cnf_jwk),
|
|
426
|
-
trusted
|
|
449
|
+
# An auth-token issuer is by definition a configured, trusted PS.
|
|
450
|
+
trusted=iss in self.config.trusted_agents or typ == AAUTH_AUTH_TYP,
|
|
427
451
|
sub=str(claims.get("sub", "")),
|
|
428
452
|
label=label,
|
|
429
453
|
claims={
|
|
430
454
|
k: claims[k]
|
|
431
|
-
for k in ("iss", "sub", "exp", "ps", "aud", "jti", "mission_s256"
|
|
455
|
+
for k in ("iss", "sub", "exp", "ps", "aud", "jti", "mission_s256",
|
|
456
|
+
"budget") # budgets: the envelope rides in the token
|
|
432
457
|
if k in claims
|
|
433
458
|
},
|
|
434
459
|
)
|
|
@@ -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
|
|
@@ -138,6 +138,72 @@ keyid-less shape is pinned in CI.
|
|
|
138
138
|
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
139
139
|
this library is the thin relying-party verifier that handles both dialects.
|
|
140
140
|
|
|
141
|
+
## Budgets: meter a spending envelope (draft-hardt-aauth-budgets)
|
|
142
|
+
|
|
143
|
+
An agent can carry a PS-issued **auth token** (`typ: aa-auth+jwt`) with a
|
|
144
|
+
`budget` claim — a spending envelope it uses offline, no per-call round trip
|
|
145
|
+
to the control plane. The middleware does the whole resource-side checklist:
|
|
146
|
+
verify the token against your pinned PS, atomically reserve → commit →
|
|
147
|
+
release per request, answer with `AAuth-Budget`, and refuse exhausted
|
|
148
|
+
envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
|
|
149
|
+
resource token with the agent's own consumption records — scoped to its key,
|
|
150
|
+
so one agent never learns about a sibling's spending):
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
|
|
154
|
+
from regent_httpsig.fastapi import BudgetMiddleware
|
|
155
|
+
|
|
156
|
+
app.add_middleware(
|
|
157
|
+
BudgetMiddleware,
|
|
158
|
+
verifier=HttpsigVerifier(HttpsigConfig(
|
|
159
|
+
resource_url="https://api.example",
|
|
160
|
+
trusted_ps={"my-ps": "https://ps.example/jwks.json"},
|
|
161
|
+
)),
|
|
162
|
+
meter=InMemoryMeter(),
|
|
163
|
+
price_fn=lambda request: PRICES.get(request.url.path), # max cost, minor units
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The only thing the library cannot do for you is pricing (`price_fn`) — that
|
|
168
|
+
is your domain. First known implementation of the draft; running in
|
|
169
|
+
production on [get4agent.com](https://get4agent.com).
|
|
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
|
+
|
|
141
207
|
## Security model (what a naive implementation gets wrong)
|
|
142
208
|
|
|
143
209
|
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=RfasLL3HAdLt5kJ2L8DeGoA3FaVXe94LIn1nzboWYKA,15095
|
|
3
|
+
regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
|
|
4
|
+
regent_httpsig/config.py,sha256=_xAIFYAyNbXzKg-famXLAs7eNdYwuvNA7NHQxcno0G4,2439
|
|
5
|
+
regent_httpsig/fastapi.py,sha256=a2m_azGEoVx9gghJTkKaHFUpUYGrxTmFK8IKW7-9AUQ,15266
|
|
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.4.0.dist-info/METADATA,sha256=FcXizrgtqWf9JnuL3ui_YtXDQFNgh-b5DG2zHzYJg-4,12023
|
|
13
|
+
regent_httpsig-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
regent_httpsig-0.4.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
|
|
15
|
+
regent_httpsig-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
16
|
+
regent_httpsig-0.4.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
regent_httpsig/__init__.py,sha256=9TWY0NncSbLGXq4mosCKEB1SGKHcIPOpYgAyOzPp_7Y,987
|
|
2
|
-
regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
|
|
3
|
-
regent_httpsig/config.py,sha256=dGWzZtS5DhCr59ecR3scX-JdHD4iZ4XFcioejz2KXDA,1954
|
|
4
|
-
regent_httpsig/fastapi.py,sha256=YmfrKdeMft2vpuiRlivIEHYC4BIs3ioR7T3t1ZEzcJ4,3707
|
|
5
|
-
regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
|
|
6
|
-
regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
|
|
7
|
-
regent_httpsig/sfv.py,sha256=kcQsBLw9h4h_6D3z2rv0-3_gLZQYC47B9VZi-oS_Vgk,4975
|
|
8
|
-
regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
|
|
9
|
-
regent_httpsig/verify.py,sha256=nMSOc1hY29X4SW2g9SD9LwLXt6ladBI7-iUU-1bYpwA,18461
|
|
10
|
-
regent_httpsig-0.2.0.dist-info/METADATA,sha256=76bz3NCU5pNPZfPWg-URUmeFvJjqOd-vIlpM03aFXEU,9129
|
|
11
|
-
regent_httpsig-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
-
regent_httpsig-0.2.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
|
|
13
|
-
regent_httpsig-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
-
regent_httpsig-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|