regent-httpsig 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regent_httpsig-0.4.0/CHANGELOG.md +98 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/PKG-INFO +67 -1
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/README.md +66 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/pyproject.toml +1 -1
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/__init__.py +34 -2
- regent_httpsig-0.4.0/src/regent_httpsig/budget.py +345 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/config.py +7 -0
- regent_httpsig-0.4.0/src/regent_httpsig/fastapi.py +358 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sfv.py +56 -0
- regent_httpsig-0.4.0/src/regent_httpsig/usage.py +223 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/verify.py +42 -17
- regent_httpsig-0.4.0/tests/test_budget.py +489 -0
- regent_httpsig-0.4.0/tests/test_usage.py +231 -0
- regent_httpsig-0.4.0/vectors/aauth-budgets-vectors.json +124 -0
- regent_httpsig-0.4.0/vectors/generate.py +157 -0
- regent_httpsig-0.2.0/CHANGELOG.md +0 -46
- regent_httpsig-0.2.0/src/regent_httpsig/fastapi.py +0 -94
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.gitignore +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/LICENSE +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/SECURITY.md +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_signer.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_vectors.py +0 -0
- {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_verifier.py +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
**AAuth Budgets — the August 20 editor's-copy additions** (allocation model,
|
|
6
|
+
omitted cost, `required`, usage counters):
|
|
7
|
+
|
|
8
|
+
- **`required` member**: an `insufficient-budget` refusal now carries the
|
|
9
|
+
refused request's maximum cost in `AAuth-Budget`, so the agent's retry is a
|
|
10
|
+
calculation (fit the bound to `remaining`) rather than a search.
|
|
11
|
+
- **Streaming / cost-omitted** (§cost-omitted): a streamed response states
|
|
12
|
+
`reserved` (REQUIRED when `cost` is omitted) with `remaining` already net of
|
|
13
|
+
the hold, commits when the stream ends (`request.state.budget_cost` may be
|
|
14
|
+
set mid-stream), and the agent recovers the exact figure from the next
|
|
15
|
+
response's `remaining`. SSE is recognized automatically; other streams opt
|
|
16
|
+
in with `request.state.budget_streaming = True`.
|
|
17
|
+
- **Usage endpoint** (§Usage Counters): `make_usage_endpoint(meter, …)` —
|
|
18
|
+
scope queries (`sub` calendar counters: day/week/month/year/all_time on UTC
|
|
19
|
+
boundaries) and per-key `jkts` queries; unrecognized scope values omit
|
|
20
|
+
`usage` (never zero — a query must not reveal whether an account exists);
|
|
21
|
+
unrecognized/pruned thumbprints are omitted from `jkts` (never zero — a
|
|
22
|
+
false zero misleads an allocation decision); per-key figures pruned on 24h
|
|
23
|
+
IDLE, so a key in continuous use is never pruned; figures keyed by the
|
|
24
|
+
issuing PS, so the endpoint structurally answers only the party whose
|
|
25
|
+
tokens were accepted. PS authentication is pluggable (`authenticate_ps`).
|
|
26
|
+
- **Signed usage responses** (§The Signed Response): `ResponseSigner` — an
|
|
27
|
+
Ed25519 HTTP Sig over `@status`, `content-type`, `content-digest`, bound to
|
|
28
|
+
the request via `@authority`/`@path` with the `req` parameter.
|
|
29
|
+
- **`validate_budget_grant`**: the §Resource Metadata MUSTs (only declared
|
|
30
|
+
units; declared decimals) as a pre-mint guard against the draft's
|
|
31
|
+
"thousandfold error".
|
|
32
|
+
|
|
33
|
+
## 0.3.0
|
|
34
|
+
|
|
35
|
+
**AAuth Budgets** (draft-hardt-aauth-budgets, editor's copy) — the resource
|
|
36
|
+
side, first known implementation (running in production on get4agent.com):
|
|
37
|
+
|
|
38
|
+
- **Auth tokens** (`typ: aa-auth+jwt`): PS-issued budget carriers verified
|
|
39
|
+
against a configuration-pinned PS (`HttpsigConfig.trusted_ps`: issuer →
|
|
40
|
+
JWKS URL), `aud`-checked against `resource_url`, `cnf`-bound, ≤1h lifetime.
|
|
41
|
+
- **`BudgetClaim` + `InMemoryMeter`**: atomic reserve → commit → release
|
|
42
|
+
pooled per the draft's `(iss, sub, aud)` aggregation key; conservative
|
|
43
|
+
crash-safety (an unresolved reservation counts as consumed); consumption
|
|
44
|
+
records **scoped to the presenting agent's `jkt`** so one agent never
|
|
45
|
+
learns about a sibling's spending.
|
|
46
|
+
- **`BudgetMiddleware`** (FastAPI): `price_fn` hook, metering cycle,
|
|
47
|
+
refusals (401 + `AAuth-Requirement` with `reason=insufficient-budget` /
|
|
48
|
+
`budget-exhausted` and an optional resource token via
|
|
49
|
+
`resource_token_provider`), per-response `AAuth-Budget` header; error
|
|
50
|
+
responses release the reservation.
|
|
51
|
+
- `build_aauth_budget_header` / `build_aauth_requirement` — RFC 9651
|
|
52
|
+
serialization (note: the field is a Dictionary, so members are
|
|
53
|
+
comma-separated; the draft's §11 example shows parameter separators).
|
|
54
|
+
|
|
55
|
+
## 0.2.0
|
|
56
|
+
|
|
57
|
+
AAuth draft **-11** support (per the editor's copy, ahead of datatracker publication):
|
|
58
|
+
|
|
59
|
+
- **Fully-specified algorithms (RFC 9864):** `Ed25519` accepted everywhere
|
|
60
|
+
(registered with PyJWT, including JWKS entries PyJWK cannot parse).
|
|
61
|
+
New `HttpsigConfig.require_fully_specified_algs` enforces the -11 MUST NOT on
|
|
62
|
+
the polymorphic `EdDSA`; the default keeps accepting it while the -10
|
|
63
|
+
ecosystem migrates, and will flip when -11 posts.
|
|
64
|
+
- **Person tokens** (`typ: aa-person+jwt`): PS-issued, per-resource `aud`,
|
|
65
|
+
`cnf`-bound, ≤1h lifetime — verified via `{iss}/.well-known/aauth-person.json`.
|
|
66
|
+
Opt-in: set `HttpsigConfig.resource_url` (the token's `aud` must name it).
|
|
67
|
+
Result scheme: `"aauth-person"`, `sub` = the PS's directed user identifier.
|
|
68
|
+
- Strict mode also enforces the -11 requirement that `cnf.jwk` carries a
|
|
69
|
+
fully-specified `alg` member.
|
|
70
|
+
|
|
71
|
+
## 0.1.1
|
|
72
|
+
|
|
73
|
+
- AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
|
|
74
|
+
the token's `cnf.jwk`). Exposed by cross-library interop with
|
|
75
|
+
christian-posta/aauth-signing, whose signers correctly omit it; that signer's
|
|
76
|
+
exact keyid-less shape is now pinned in CI.
|
|
77
|
+
|
|
78
|
+
## 0.1.0
|
|
79
|
+
|
|
80
|
+
Initial release, extracted from Regent Protocol's production marketplace
|
|
81
|
+
(get4agent.com), where it authenticates self-onboarding AI agents.
|
|
82
|
+
|
|
83
|
+
- `HttpsigVerifier` — RFC 9421 verification for both agent dialects:
|
|
84
|
+
- Web Bot Auth (draft -05): sf-dictionary `Signature-Agent` with `;key=`
|
|
85
|
+
member selection AND the legacy sf-string form OpenAI ships in production;
|
|
86
|
+
key discovery via `/.well-known/http-message-signatures-directory`.
|
|
87
|
+
- AAuth (identity-based mode, `[aauth]` extra): `aa-agent+jwt` in
|
|
88
|
+
`Signature-Key`, issuer JWKS discovery, `cnf.jwk` proof of possession.
|
|
89
|
+
- SSRF-guarded directory fetching (https-only, public-IP-only, no redirects,
|
|
90
|
+
size-capped) with bounded per-instance caching.
|
|
91
|
+
- `EgressSigner` + `regent-httpsig keygen` — sign outbound agent traffic
|
|
92
|
+
(Web Bot Auth), generate keys and ready-to-publish well-known files.
|
|
93
|
+
- FastAPI integration (`[fastapi]` extra): `SignatureDep` (enrichment) and
|
|
94
|
+
`RequiredSignatureDep` (authentication with a self-explaining 401).
|
|
95
|
+
- Test suite pinned to the official RFC 9421 B.2.6 vector, both Web Bot Auth
|
|
96
|
+
appendix vectors (A.2.2 re-signed — the draft's printed signature does not
|
|
97
|
+
verify over its own base; reported), and full sign→verify roundtrips for
|
|
98
|
+
both dialects.
|
|
@@ -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
|
|
@@ -108,6 +108,72 @@ keyid-less shape is pinned in CI.
|
|
|
108
108
|
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
109
109
|
this library is the thin relying-party verifier that handles both dialects.
|
|
110
110
|
|
|
111
|
+
## Budgets: meter a spending envelope (draft-hardt-aauth-budgets)
|
|
112
|
+
|
|
113
|
+
An agent can carry a PS-issued **auth token** (`typ: aa-auth+jwt`) with a
|
|
114
|
+
`budget` claim — a spending envelope it uses offline, no per-call round trip
|
|
115
|
+
to the control plane. The middleware does the whole resource-side checklist:
|
|
116
|
+
verify the token against your pinned PS, atomically reserve → commit →
|
|
117
|
+
release per request, answer with `AAuth-Budget`, and refuse exhausted
|
|
118
|
+
envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
|
|
119
|
+
resource token with the agent's own consumption records — scoped to its key,
|
|
120
|
+
so one agent never learns about a sibling's spending):
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
|
|
124
|
+
from regent_httpsig.fastapi import BudgetMiddleware
|
|
125
|
+
|
|
126
|
+
app.add_middleware(
|
|
127
|
+
BudgetMiddleware,
|
|
128
|
+
verifier=HttpsigVerifier(HttpsigConfig(
|
|
129
|
+
resource_url="https://api.example",
|
|
130
|
+
trusted_ps={"my-ps": "https://ps.example/jwks.json"},
|
|
131
|
+
)),
|
|
132
|
+
meter=InMemoryMeter(),
|
|
133
|
+
price_fn=lambda request: PRICES.get(request.url.path), # max cost, minor units
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The only thing the library cannot do for you is pricing (`price_fn`) — that
|
|
138
|
+
is your domain. First known implementation of the draft; running in
|
|
139
|
+
production on [get4agent.com](https://get4agent.com).
|
|
140
|
+
|
|
141
|
+
**The August 20 additions** are covered too:
|
|
142
|
+
|
|
143
|
+
- `insufficient-budget` refusals carry **`required`** — the refused request's
|
|
144
|
+
maximum cost — so the agent lowers its bound and retries instead of guessing.
|
|
145
|
+
- **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
|
|
146
|
+
the header, commit when the stream ends (set `request.state.budget_cost`
|
|
147
|
+
mid-stream if you learn the actual), and the agent recovers the exact cost
|
|
148
|
+
from the next response's `remaining`.
|
|
149
|
+
- The **usage endpoint** (§Usage Counters) lets your PS query consumption
|
|
150
|
+
without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
|
|
151
|
+
and per-key `jkts` totals, optionally signed:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
|
|
155
|
+
|
|
156
|
+
handler = make_usage_endpoint(
|
|
157
|
+
meter,
|
|
158
|
+
authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
|
|
159
|
+
unit="USD", decimals=6,
|
|
160
|
+
signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@app.post("/usage")
|
|
164
|
+
async def usage(request: Request):
|
|
165
|
+
return await handler(request)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
|
|
169
|
+
metadata MUSTs before you mint a resource token — the "thousandfold error"
|
|
170
|
+
guard.
|
|
171
|
+
|
|
172
|
+
**Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
|
|
173
|
+
header serializations (including `required` and the cost-omitted streaming
|
|
174
|
+
case), the budget object, consumption records, and a fully worked signed
|
|
175
|
+
usage response with a fixed key, ready for cross-implementation checks.
|
|
176
|
+
|
|
111
177
|
## Security model (what a naive implementation gets wrong)
|
|
112
178
|
|
|
113
179
|
The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-httpsig"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -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",
|
|
@@ -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
|
+
]
|