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.
Files changed (28) hide show
  1. regent_httpsig-0.3.0/CHANGELOG.md +68 -0
  2. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/PKG-INFO +40 -1
  3. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/README.md +39 -0
  4. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/pyproject.toml +1 -1
  5. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/__init__.py +20 -2
  6. regent_httpsig-0.3.0/src/regent_httpsig/budget.py +248 -0
  7. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/config.py +15 -0
  8. regent_httpsig-0.3.0/src/regent_httpsig/fastapi.py +307 -0
  9. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/sfv.py +50 -0
  10. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/verify.py +109 -17
  11. regent_httpsig-0.3.0/tests/test_budget.py +449 -0
  12. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_verifier.py +109 -0
  13. regent_httpsig-0.1.1/CHANGELOG.md +0 -30
  14. regent_httpsig-0.1.1/src/regent_httpsig/fastapi.py +0 -94
  15. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.github/workflows/release.yml +0 -0
  17. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/.gitignore +0 -0
  18. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/LICENSE +0 -0
  19. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/SECURITY.md +0 -0
  20. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/examples/fastapi_verify.py +0 -0
  21. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/examples/httpx_signer.py +0 -0
  22. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/cli.py +0 -0
  23. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/jwk.py +0 -0
  24. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/netguard.py +0 -0
  25. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/src/regent_httpsig/sign.py +0 -0
  26. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_netguard.py +0 -0
  27. {regent_httpsig-0.1.1 → regent_httpsig-0.3.0}/tests/test_signer.py +0 -0
  28. {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.1.1
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.1.1"
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 parse_signature_agent
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.1.1"
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)