regent-httpsig 0.1.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regent_httpsig-0.3.0/CHANGELOG.md +68 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/PKG-INFO +40 -1
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/README.md +39 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/pyproject.toml +1 -1
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/__init__.py +20 -2
- regent_httpsig-0.3.0/src/regent_httpsig/budget.py +248 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/config.py +15 -0
- regent_httpsig-0.3.0/src/regent_httpsig/fastapi.py +307 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/sfv.py +50 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/verify.py +109 -17
- regent_httpsig-0.3.0/tests/test_budget.py +449 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_verifier.py +109 -0
- regent_httpsig-0.1.1/CHANGELOG.md +0 -30
- regent_httpsig-0.1.1/src/regent_httpsig/fastapi.py +0 -94
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.gitignore +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/LICENSE +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/SECURITY.md +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_signer.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_vectors.py +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
**AAuth Budgets** (draft-hardt-aauth-budgets, editor's copy) — the resource
|
|
6
|
+
side, first known implementation (running in production on get4agent.com):
|
|
7
|
+
|
|
8
|
+
- **Auth tokens** (`typ: aa-auth+jwt`): PS-issued budget carriers verified
|
|
9
|
+
against a configuration-pinned PS (`HttpsigConfig.trusted_ps`: issuer →
|
|
10
|
+
JWKS URL), `aud`-checked against `resource_url`, `cnf`-bound, ≤1h lifetime.
|
|
11
|
+
- **`BudgetClaim` + `InMemoryMeter`**: atomic reserve → commit → release
|
|
12
|
+
pooled per the draft's `(iss, sub, aud)` aggregation key; conservative
|
|
13
|
+
crash-safety (an unresolved reservation counts as consumed); consumption
|
|
14
|
+
records **scoped to the presenting agent's `jkt`** so one agent never
|
|
15
|
+
learns about a sibling's spending.
|
|
16
|
+
- **`BudgetMiddleware`** (FastAPI): `price_fn` hook, metering cycle,
|
|
17
|
+
refusals (401 + `AAuth-Requirement` with `reason=insufficient-budget` /
|
|
18
|
+
`budget-exhausted` and an optional resource token via
|
|
19
|
+
`resource_token_provider`), per-response `AAuth-Budget` header; error
|
|
20
|
+
responses release the reservation.
|
|
21
|
+
- `build_aauth_budget_header` / `build_aauth_requirement` — RFC 9651
|
|
22
|
+
serialization (note: the field is a Dictionary, so members are
|
|
23
|
+
comma-separated; the draft's §11 example shows parameter separators).
|
|
24
|
+
|
|
25
|
+
## 0.2.0
|
|
26
|
+
|
|
27
|
+
AAuth draft **-11** support (per the editor's copy, ahead of datatracker publication):
|
|
28
|
+
|
|
29
|
+
- **Fully-specified algorithms (RFC 9864):** `Ed25519` accepted everywhere
|
|
30
|
+
(registered with PyJWT, including JWKS entries PyJWK cannot parse).
|
|
31
|
+
New `HttpsigConfig.require_fully_specified_algs` enforces the -11 MUST NOT on
|
|
32
|
+
the polymorphic `EdDSA`; the default keeps accepting it while the -10
|
|
33
|
+
ecosystem migrates, and will flip when -11 posts.
|
|
34
|
+
- **Person tokens** (`typ: aa-person+jwt`): PS-issued, per-resource `aud`,
|
|
35
|
+
`cnf`-bound, ≤1h lifetime — verified via `{iss}/.well-known/aauth-person.json`.
|
|
36
|
+
Opt-in: set `HttpsigConfig.resource_url` (the token's `aud` must name it).
|
|
37
|
+
Result scheme: `"aauth-person"`, `sub` = the PS's directed user identifier.
|
|
38
|
+
- Strict mode also enforces the -11 requirement that `cnf.jwk` carries a
|
|
39
|
+
fully-specified `alg` member.
|
|
40
|
+
|
|
41
|
+
## 0.1.1
|
|
42
|
+
|
|
43
|
+
- AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
|
|
44
|
+
the token's `cnf.jwk`). Exposed by cross-library interop with
|
|
45
|
+
christian-posta/aauth-signing, whose signers correctly omit it; that signer's
|
|
46
|
+
exact keyid-less shape is now pinned in CI.
|
|
47
|
+
|
|
48
|
+
## 0.1.0
|
|
49
|
+
|
|
50
|
+
Initial release, extracted from Regent Protocol's production marketplace
|
|
51
|
+
(get4agent.com), where it authenticates self-onboarding AI agents.
|
|
52
|
+
|
|
53
|
+
- `HttpsigVerifier` — RFC 9421 verification for both agent dialects:
|
|
54
|
+
- Web Bot Auth (draft -05): sf-dictionary `Signature-Agent` with `;key=`
|
|
55
|
+
member selection AND the legacy sf-string form OpenAI ships in production;
|
|
56
|
+
key discovery via `/.well-known/http-message-signatures-directory`.
|
|
57
|
+
- AAuth (identity-based mode, `[aauth]` extra): `aa-agent+jwt` in
|
|
58
|
+
`Signature-Key`, issuer JWKS discovery, `cnf.jwk` proof of possession.
|
|
59
|
+
- SSRF-guarded directory fetching (https-only, public-IP-only, no redirects,
|
|
60
|
+
size-capped) with bounded per-instance caching.
|
|
61
|
+
- `EgressSigner` + `regent-httpsig keygen` — sign outbound agent traffic
|
|
62
|
+
(Web Bot Auth), generate keys and ready-to-publish well-known files.
|
|
63
|
+
- FastAPI integration (`[fastapi]` extra): `SignatureDep` (enrichment) and
|
|
64
|
+
`RequiredSignatureDep` (authentication with a self-explaining 401).
|
|
65
|
+
- Test suite pinned to the official RFC 9421 B.2.6 vector, both Web Bot Auth
|
|
66
|
+
appendix vectors (A.2.2 re-signed — the draft's printed signature does not
|
|
67
|
+
verify over its own base; reported), and full sign→verify roundtrips for
|
|
68
|
+
both dialects.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -72,6 +72,12 @@ signature yields `None`, and nothing ever raises on untrusted input. Use
|
|
|
72
72
|
`regent_httpsig.fastapi.RequiredSignatureDep` when a signature must be present — the 401
|
|
73
73
|
tells the agent exactly how to sign.
|
|
74
74
|
|
|
75
|
+
> **Behind a reverse proxy?** The agent signed the *public* URL
|
|
76
|
+
> (`https://api.example/…`), but your ASGI server sees `http://container/…`. The FastAPI
|
|
77
|
+
> dependency rebuilds the signed URL from `X-Forwarded-Proto` + `Host`, so make sure your
|
|
78
|
+
> proxy forwards the scheme — nginx: `proxy_set_header X-Forwarded-Proto $scheme;`.
|
|
79
|
+
> If signatures mysteriously fail to verify in production, check this first.
|
|
80
|
+
|
|
75
81
|
## Sign: get your agent past bot walls
|
|
76
82
|
|
|
77
83
|
```python
|
|
@@ -125,10 +131,43 @@ keyid-less shape is pinned in CI.
|
|
|
125
131
|
- **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
|
|
126
132
|
JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
|
|
127
133
|
`cnf.jwk` verifies the request signature. Install with `pip install 'regent-httpsig[aauth]'`.
|
|
134
|
+
Tracks the **-11 editor's copy**: fully-specified algorithms (RFC 9864, `Ed25519` — with a
|
|
135
|
+
transition flag for the -10 ecosystem's `EdDSA`) and **person tokens** (`aa-person+jwt`,
|
|
136
|
+
opt-in via `HttpsigConfig.resource_url`).
|
|
128
137
|
For a full-protocol AAuth implementation (both roles, all token types) see
|
|
129
138
|
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
130
139
|
this library is the thin relying-party verifier that handles both dialects.
|
|
131
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
|
+
|
|
132
171
|
## Security model (what a naive implementation gets wrong)
|
|
133
172
|
|
|
134
173
|
The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
|
|
@@ -42,6 +42,12 @@ signature yields `None`, and nothing ever raises on untrusted input. Use
|
|
|
42
42
|
`regent_httpsig.fastapi.RequiredSignatureDep` when a signature must be present — the 401
|
|
43
43
|
tells the agent exactly how to sign.
|
|
44
44
|
|
|
45
|
+
> **Behind a reverse proxy?** The agent signed the *public* URL
|
|
46
|
+
> (`https://api.example/…`), but your ASGI server sees `http://container/…`. The FastAPI
|
|
47
|
+
> dependency rebuilds the signed URL from `X-Forwarded-Proto` + `Host`, so make sure your
|
|
48
|
+
> proxy forwards the scheme — nginx: `proxy_set_header X-Forwarded-Proto $scheme;`.
|
|
49
|
+
> If signatures mysteriously fail to verify in production, check this first.
|
|
50
|
+
|
|
45
51
|
## Sign: get your agent past bot walls
|
|
46
52
|
|
|
47
53
|
```python
|
|
@@ -95,10 +101,43 @@ keyid-less shape is pinned in CI.
|
|
|
95
101
|
- **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
|
|
96
102
|
JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
|
|
97
103
|
`cnf.jwk` verifies the request signature. Install with `pip install 'regent-httpsig[aauth]'`.
|
|
104
|
+
Tracks the **-11 editor's copy**: fully-specified algorithms (RFC 9864, `Ed25519` — with a
|
|
105
|
+
transition flag for the -10 ecosystem's `EdDSA`) and **person tokens** (`aa-person+jwt`,
|
|
106
|
+
opt-in via `HttpsigConfig.resource_url`).
|
|
98
107
|
For a full-protocol AAuth implementation (both roles, all token types) see
|
|
99
108
|
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
100
109
|
this library is the thin relying-party verifier that handles both dialects.
|
|
101
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
|
+
|
|
102
141
|
## Security model (what a naive implementation gets wrong)
|
|
103
142
|
|
|
104
143
|
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.3.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,44 @@ 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.sfv import
|
|
17
|
+
from regent_httpsig.sfv import (
|
|
18
|
+
build_aauth_budget_header,
|
|
19
|
+
build_aauth_requirement,
|
|
20
|
+
parse_signature_agent,
|
|
21
|
+
)
|
|
11
22
|
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
12
23
|
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
|
|
13
24
|
|
|
14
|
-
__version__ = "0.
|
|
25
|
+
__version__ = "0.3.0"
|
|
15
26
|
|
|
16
27
|
__all__ = [
|
|
17
28
|
"DIRECTORY_MEDIA_TYPE",
|
|
29
|
+
"BudgetClaim",
|
|
18
30
|
"EgressSigner",
|
|
19
31
|
"HttpsigConfig",
|
|
20
32
|
"HttpsigVerifier",
|
|
33
|
+
"InMemoryMeter",
|
|
34
|
+
"InsufficientBudget",
|
|
35
|
+
"InvalidBudgetClaim",
|
|
21
36
|
"NotPublicURL",
|
|
37
|
+
"UnitMismatch",
|
|
22
38
|
"VerifiedSignature",
|
|
23
39
|
"WBA_TAG",
|
|
24
40
|
"__version__",
|
|
25
41
|
"assert_public_url",
|
|
26
42
|
"b64url",
|
|
43
|
+
"build_aauth_budget_header",
|
|
44
|
+
"build_aauth_requirement",
|
|
27
45
|
"generate_seed",
|
|
28
46
|
"jwk_thumbprint",
|
|
29
47
|
"load_ed25519_jwk",
|
|
@@ -0,0 +1,248 @@
|
|
|
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 dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BudgetClaim",
|
|
29
|
+
"InMemoryMeter",
|
|
30
|
+
"InsufficientBudget",
|
|
31
|
+
"InvalidBudgetClaim",
|
|
32
|
+
"Reservation",
|
|
33
|
+
"UnitMismatch",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
MeterKey = tuple[str, str, str] # (iss, sub, aud) — the draft's aggregation key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidBudgetClaim(ValueError):
|
|
40
|
+
"""A ``budget`` member is present but malformed (issuer bug — not spendable)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnitMismatch(ValueError):
|
|
44
|
+
"""A grant's unit/decimals differ from the pool's — one envelope, one unit."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class BudgetClaim:
|
|
49
|
+
"""The ``budget`` claim: integer amount in ``unit`` scaled by ``decimals``.
|
|
50
|
+
|
|
51
|
+
``amount=5000000, unit="USD", decimals=6`` is $5.00 — all arithmetic stays
|
|
52
|
+
in integers; the scale only matters at display time.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
amount: int
|
|
56
|
+
unit: str
|
|
57
|
+
decimals: int
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
|
|
61
|
+
"""Extract the claim from a token's claim set. ``None`` when absent;
|
|
62
|
+
:class:`InvalidBudgetClaim` when present but malformed (all three
|
|
63
|
+
members are REQUIRED, integers must be non-negative, bools are not
|
|
64
|
+
integers here)."""
|
|
65
|
+
raw = claims.get("budget")
|
|
66
|
+
if raw is None:
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(raw, Mapping):
|
|
69
|
+
raise InvalidBudgetClaim("budget claim must be an object")
|
|
70
|
+
amount, unit, decimals = raw.get("amount"), raw.get("unit"), raw.get("decimals")
|
|
71
|
+
if (
|
|
72
|
+
isinstance(amount, bool) or not isinstance(amount, int) or amount < 0
|
|
73
|
+
or not isinstance(unit, str) or not unit
|
|
74
|
+
or isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0
|
|
75
|
+
):
|
|
76
|
+
raise InvalidBudgetClaim("budget claim requires amount/unit/decimals")
|
|
77
|
+
return BudgetClaim(amount=amount, unit=unit, decimals=decimals)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Reservation:
|
|
82
|
+
"""An atomic hold on the pool for one in-flight request. Never revised —
|
|
83
|
+
committed (with the actual cost) or released, exactly once."""
|
|
84
|
+
|
|
85
|
+
rid: int
|
|
86
|
+
key: MeterKey
|
|
87
|
+
jti: str
|
|
88
|
+
amount: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class InsufficientBudget:
|
|
93
|
+
"""Refusal: the request's maximum cost exceeds the pool's remaining balance.
|
|
94
|
+
``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
|
|
95
|
+
(``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
|
|
96
|
+
|
|
97
|
+
remaining: int
|
|
98
|
+
exhausted: bool
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _Pool:
|
|
103
|
+
unit: str
|
|
104
|
+
decimals: int
|
|
105
|
+
grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
|
|
106
|
+
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
|
|
107
|
+
jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
|
|
108
|
+
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
|
|
109
|
+
last_activity: float = 0.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class InMemoryMeter:
|
|
113
|
+
"""Single-process meter (asyncio-safe). Right for a single-instance service;
|
|
114
|
+
multi-replica deployments need a shared backend behind the same interface.
|
|
115
|
+
|
|
116
|
+
Crash-safety is conservative: a reservation not committed or released within
|
|
117
|
+
``reservation_ttl`` seconds is treated as fully consumed — the owner's
|
|
118
|
+
envelope is never silently under-counted by a crashed handler.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, *, reservation_ttl: float = 120.0,
|
|
122
|
+
retention_seconds: float = 7200.0) -> None:
|
|
123
|
+
self._pools: dict[MeterKey, _Pool] = {}
|
|
124
|
+
self._lock = asyncio.Lock()
|
|
125
|
+
self._rids = itertools.count(1)
|
|
126
|
+
self._reservation_ttl = reservation_ttl
|
|
127
|
+
self._retention = retention_seconds
|
|
128
|
+
|
|
129
|
+
# ── internals (call under lock) ──────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _purge(self, key: MeterKey, now: float) -> _Pool | None:
|
|
132
|
+
pool = self._pools.get(key)
|
|
133
|
+
if pool is None:
|
|
134
|
+
return None
|
|
135
|
+
# Expired, unresolved reservations count as consumed (conservative).
|
|
136
|
+
for rid, (jti, amount, deadline) in list(pool.reservations.items()):
|
|
137
|
+
if deadline <= now:
|
|
138
|
+
pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
|
|
139
|
+
del pool.reservations[rid]
|
|
140
|
+
# Expired grants leave the pool; their consumption records remain for
|
|
141
|
+
# budget_consumed reporting until the retention window passes.
|
|
142
|
+
for jti, (_, exp) in list(pool.grants.items()):
|
|
143
|
+
if exp <= now:
|
|
144
|
+
del pool.grants[jti]
|
|
145
|
+
if (not pool.grants and not pool.reservations
|
|
146
|
+
and now - pool.last_activity > self._retention):
|
|
147
|
+
del self._pools[key]
|
|
148
|
+
return None
|
|
149
|
+
return pool
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _remaining(pool: _Pool) -> int:
|
|
153
|
+
live = sum(a for a, _ in pool.grants.values())
|
|
154
|
+
spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
|
|
155
|
+
held = sum(a for _, a, _ in pool.reservations.values())
|
|
156
|
+
return max(0, live - spent - held)
|
|
157
|
+
|
|
158
|
+
# ── public interface (the BudgetMeter contract) ──────────────────────────
|
|
159
|
+
|
|
160
|
+
async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
|
|
161
|
+
exp: float, jkt: str = "") -> None:
|
|
162
|
+
"""Register a token's envelope in the principal's pool (idempotent per
|
|
163
|
+
``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
|
|
164
|
+
recorded so consumption records can be scoped to the presenting agent
|
|
165
|
+
(one agent must not learn about its siblings). Raises
|
|
166
|
+
:class:`UnitMismatch` if the pool already runs in a different unit —
|
|
167
|
+
one envelope, one unit, no FX at the meter."""
|
|
168
|
+
async with self._lock:
|
|
169
|
+
now = time.monotonic()
|
|
170
|
+
wall_delta = exp - time.time()
|
|
171
|
+
pool = self._purge(key, now)
|
|
172
|
+
if pool is None:
|
|
173
|
+
pool = self._pools.setdefault(
|
|
174
|
+
key, _Pool(unit=claim.unit, decimals=claim.decimals))
|
|
175
|
+
if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
|
|
176
|
+
raise UnitMismatch(
|
|
177
|
+
f"pool runs in {pool.unit}/{pool.decimals}, "
|
|
178
|
+
f"grant is {claim.unit}/{claim.decimals}")
|
|
179
|
+
pool.last_activity = now
|
|
180
|
+
if jkt:
|
|
181
|
+
pool.jkt_of.setdefault(jti, jkt)
|
|
182
|
+
if jti not in pool.grants and wall_delta > 0:
|
|
183
|
+
pool.grants[jti] = (claim.amount, now + wall_delta)
|
|
184
|
+
|
|
185
|
+
async def reserve(self, key: MeterKey, jti: str,
|
|
186
|
+
max_cost: int) -> Reservation | InsufficientBudget:
|
|
187
|
+
async with self._lock:
|
|
188
|
+
now = time.monotonic()
|
|
189
|
+
pool = self._purge(key, now)
|
|
190
|
+
if pool is None or jti not in pool.grants:
|
|
191
|
+
return InsufficientBudget(remaining=0, exhausted=True)
|
|
192
|
+
remaining = self._remaining(pool)
|
|
193
|
+
if max_cost > remaining:
|
|
194
|
+
return InsufficientBudget(remaining=remaining,
|
|
195
|
+
exhausted=remaining == 0)
|
|
196
|
+
rid = next(self._rids)
|
|
197
|
+
pool.reservations[rid] = (jti, max_cost, now + self._reservation_ttl)
|
|
198
|
+
pool.last_activity = now
|
|
199
|
+
return Reservation(rid=rid, key=key, jti=jti, amount=max_cost)
|
|
200
|
+
|
|
201
|
+
async def commit(self, res: Reservation, actual: int) -> int:
|
|
202
|
+
"""Commit the actual cost (clamped to the reserved amount — reservations
|
|
203
|
+
are never revised upward) and return the pool's remaining balance."""
|
|
204
|
+
async with self._lock:
|
|
205
|
+
now = time.monotonic()
|
|
206
|
+
pool = self._purge(res.key, now)
|
|
207
|
+
if pool is None:
|
|
208
|
+
return 0
|
|
209
|
+
held = pool.reservations.pop(res.rid, None)
|
|
210
|
+
cost = min(max(actual, 0), held[1] if held else res.amount)
|
|
211
|
+
pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
|
|
212
|
+
pool.last_activity = now
|
|
213
|
+
return self._remaining(pool)
|
|
214
|
+
|
|
215
|
+
async def release(self, res: Reservation) -> int:
|
|
216
|
+
async with self._lock:
|
|
217
|
+
pool = self._purge(res.key, time.monotonic())
|
|
218
|
+
if pool is None:
|
|
219
|
+
return 0
|
|
220
|
+
pool.reservations.pop(res.rid, None)
|
|
221
|
+
return self._remaining(pool)
|
|
222
|
+
|
|
223
|
+
async def remaining(self, key: MeterKey) -> int:
|
|
224
|
+
async with self._lock:
|
|
225
|
+
pool = self._purge(key, time.monotonic())
|
|
226
|
+
return 0 if pool is None else self._remaining(pool)
|
|
227
|
+
|
|
228
|
+
async def consumed_records(self, key: MeterKey,
|
|
229
|
+
jkt: str | None = None) -> list[dict[str, Any]]:
|
|
230
|
+
"""Per-token consumption for the resource token's ``budget_consumed``
|
|
231
|
+
claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
|
|
232
|
+
PS deduplicates by ``jti``, so reporting the same record twice is safe.
|
|
233
|
+
|
|
234
|
+
When ``jkt`` is given, records are scoped to tokens bound to that key:
|
|
235
|
+
the agent carrying the resource token sees only its OWN spending, never
|
|
236
|
+
its siblings' (privacy between a principal's agents, and no extra
|
|
237
|
+
figures to infer the ceiling from). Consequence: an abandoned agent's
|
|
238
|
+
records are never carried home by siblings — the PS-side conservative
|
|
239
|
+
rule (unreported expired allocation = fully consumed) is the backstop."""
|
|
240
|
+
async with self._lock:
|
|
241
|
+
pool = self._purge(key, time.monotonic())
|
|
242
|
+
if pool is None:
|
|
243
|
+
return []
|
|
244
|
+
return [
|
|
245
|
+
{"jti": jti, "consumed": total}
|
|
246
|
+
for jti, total in sorted(pool.consumed.items())
|
|
247
|
+
if total > 0 and (jkt is None or pool.jkt_of.get(jti) == jkt)
|
|
248
|
+
]
|
|
@@ -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"]
|
|
@@ -30,3 +31,17 @@ class HttpsigConfig:
|
|
|
30
31
|
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
|
|
31
32
|
# e.g. frozenset({"localhost"})). Leave empty in production.
|
|
32
33
|
insecure_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
34
|
+
# AAuth -11 (editor's copy): JOSE algs must be fully-specified per RFC 9864 —
|
|
35
|
+
# implementations MUST NOT accept the polymorphic "EdDSA". True enforces that;
|
|
36
|
+
# the False default keeps accepting "EdDSA" while the -10 ecosystem migrates.
|
|
37
|
+
require_fully_specified_algs: bool = False
|
|
38
|
+
# This service's public URL (e.g. "https://api.example"). Required to accept
|
|
39
|
+
# AAuth person tokens — their `aud` must name this resource. None disables
|
|
40
|
+
# the person-token path entirely.
|
|
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)
|