regent-httpsig 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regent_httpsig/__init__.py +33 -7
- regent_httpsig/budget.py +104 -4
- regent_httpsig/config.py +31 -16
- regent_httpsig/fastapi.py +189 -106
- regent_httpsig/sfv.py +58 -6
- regent_httpsig/sign.py +232 -24
- regent_httpsig/usage.py +119 -14
- regent_httpsig/verify.py +456 -202
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/METADATA +67 -24
- regent_httpsig-0.7.0.dist-info/RECORD +16 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/WHEEL +1 -1
- regent_httpsig-0.5.1.dist-info/RECORD +0 -16
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.5.1.dist-info → regent_httpsig-0.7.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -9,28 +9,45 @@ from regent_httpsig.budget import (
|
|
|
9
9
|
InMemoryMeter,
|
|
10
10
|
InsufficientBudget,
|
|
11
11
|
InvalidBudgetClaim,
|
|
12
|
+
TokenRevoked,
|
|
12
13
|
UnitMismatch,
|
|
13
14
|
)
|
|
14
15
|
from regent_httpsig.config import HttpsigConfig
|
|
15
16
|
from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
|
|
16
17
|
from regent_httpsig.netguard import NotPublicURL, assert_public_url
|
|
18
|
+
from regent_httpsig.sfv import (
|
|
19
|
+
build_aauth_budget_header,
|
|
20
|
+
build_aauth_requirement,
|
|
21
|
+
build_person_token_requirement,
|
|
22
|
+
parse_signature_agent,
|
|
23
|
+
parse_signature_input,
|
|
24
|
+
parse_signature_key,
|
|
25
|
+
)
|
|
26
|
+
from regent_httpsig.sign import (
|
|
27
|
+
DIRECTORY_MEDIA_TYPE,
|
|
28
|
+
EgressSigner,
|
|
29
|
+
ServerSigner,
|
|
30
|
+
content_digest,
|
|
31
|
+
generate_seed,
|
|
32
|
+
sign_request,
|
|
33
|
+
)
|
|
17
34
|
from regent_httpsig.usage import (
|
|
18
35
|
ResponseSigner,
|
|
19
36
|
UsageQueryError,
|
|
20
37
|
build_usage_response,
|
|
38
|
+
make_revocation_endpoint,
|
|
21
39
|
make_usage_endpoint,
|
|
22
40
|
parse_usage_request,
|
|
23
41
|
validate_budget_grant,
|
|
24
42
|
)
|
|
25
|
-
from regent_httpsig.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
from regent_httpsig.verify import (
|
|
44
|
+
WBA_TAG,
|
|
45
|
+
HttpsigVerifier,
|
|
46
|
+
VerificationError,
|
|
47
|
+
VerifiedSignature,
|
|
29
48
|
)
|
|
30
|
-
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
31
|
-
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
|
|
32
49
|
|
|
33
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.7.0"
|
|
34
51
|
|
|
35
52
|
__all__ = [
|
|
36
53
|
"DIRECTORY_MEDIA_TYPE",
|
|
@@ -40,10 +57,18 @@ __all__ = [
|
|
|
40
57
|
"HttpsigVerifier",
|
|
41
58
|
"InMemoryMeter",
|
|
42
59
|
"InsufficientBudget",
|
|
60
|
+
"TokenRevoked",
|
|
43
61
|
"InvalidBudgetClaim",
|
|
44
62
|
"NotPublicURL",
|
|
45
63
|
"UnitMismatch",
|
|
64
|
+
"VerificationError",
|
|
46
65
|
"VerifiedSignature",
|
|
66
|
+
"ServerSigner",
|
|
67
|
+
"content_digest",
|
|
68
|
+
"sign_request",
|
|
69
|
+
"build_person_token_requirement",
|
|
70
|
+
"parse_signature_input",
|
|
71
|
+
"parse_signature_key",
|
|
47
72
|
"WBA_TAG",
|
|
48
73
|
"__version__",
|
|
49
74
|
"assert_public_url",
|
|
@@ -51,6 +76,7 @@ __all__ = [
|
|
|
51
76
|
"ResponseSigner",
|
|
52
77
|
"UsageQueryError",
|
|
53
78
|
"build_usage_response",
|
|
79
|
+
"make_revocation_endpoint",
|
|
54
80
|
"make_usage_endpoint",
|
|
55
81
|
"parse_usage_request",
|
|
56
82
|
"validate_budget_grant",
|
regent_httpsig/budget.py
CHANGED
|
@@ -33,8 +33,8 @@ import asyncio
|
|
|
33
33
|
import itertools
|
|
34
34
|
import time
|
|
35
35
|
from collections.abc import Mapping
|
|
36
|
-
from datetime import UTC, datetime, timedelta
|
|
37
36
|
from dataclasses import dataclass, field
|
|
37
|
+
from datetime import UTC, datetime, timedelta
|
|
38
38
|
from typing import Any
|
|
39
39
|
|
|
40
40
|
__all__ = [
|
|
@@ -43,6 +43,7 @@ __all__ = [
|
|
|
43
43
|
"InsufficientBudget",
|
|
44
44
|
"InvalidBudgetClaim",
|
|
45
45
|
"Reservation",
|
|
46
|
+
"TokenRevoked",
|
|
46
47
|
"UnitMismatch",
|
|
47
48
|
]
|
|
48
49
|
|
|
@@ -112,6 +113,17 @@ class InsufficientBudget:
|
|
|
112
113
|
exhausted: bool
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class TokenRevoked:
|
|
118
|
+
"""Refusal: the presented token was revoked (base protocol §Token
|
|
119
|
+
Revocation, budgets §Token Scope). The budget ended with the token — no
|
|
120
|
+
new spend — while requests already in flight complete. ``drained`` says
|
|
121
|
+
whether those have all settled: only then may the resource state the
|
|
122
|
+
token's consumption record, which is then FINAL (AAuth issue #151)."""
|
|
123
|
+
|
|
124
|
+
drained: bool
|
|
125
|
+
|
|
126
|
+
|
|
115
127
|
@dataclass
|
|
116
128
|
class _Pool:
|
|
117
129
|
unit: str
|
|
@@ -120,6 +132,7 @@ class _Pool:
|
|
|
120
132
|
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
|
|
121
133
|
jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
|
|
122
134
|
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
|
|
135
|
+
revoked: dict[str, float] = field(default_factory=dict) # jti -> wall time of revocation
|
|
123
136
|
last_activity: float = 0.0
|
|
124
137
|
|
|
125
138
|
|
|
@@ -178,6 +191,11 @@ class InMemoryMeter:
|
|
|
178
191
|
retention_seconds: float = 7200.0,
|
|
179
192
|
usage_key_retention: float = 86400.0) -> None:
|
|
180
193
|
self._pools: dict[MeterKey, _Pool] = {}
|
|
194
|
+
self._jti_index: dict[tuple[str, str], MeterKey] = {} # (iss, jti) -> ledger key
|
|
195
|
+
# -11 §11.12.1: revocations keyed by (iss, jti), kept until the token's
|
|
196
|
+
# own exp plus clock skew — whether or not the token was ever seen here.
|
|
197
|
+
self._revocations: dict[tuple[str, str], float] = {} # (iss, jti) -> exp (wall)
|
|
198
|
+
self._revocation_skew = 60.0
|
|
181
199
|
self._lock = asyncio.Lock()
|
|
182
200
|
self._rids = itertools.count(1)
|
|
183
201
|
self._reservation_ttl = reservation_ttl
|
|
@@ -190,7 +208,8 @@ class InMemoryMeter:
|
|
|
190
208
|
# request" — so a key in continuous use is never pruned.
|
|
191
209
|
self._usage_key_retention = usage_key_retention
|
|
192
210
|
self._scope_usage: dict[tuple[str, str], _ScopeCounters] = {} # (iss, sub)
|
|
193
|
-
|
|
211
|
+
# (iss, jkt) -> (total, last_wall)
|
|
212
|
+
self._key_usage: dict[tuple[str, str], tuple[int, float]] = {}
|
|
194
213
|
self._metering_unit: tuple[str, int] | None = None
|
|
195
214
|
|
|
196
215
|
# ── internals (call under lock) ──────────────────────────────────────────
|
|
@@ -213,9 +232,16 @@ class InMemoryMeter:
|
|
|
213
232
|
if (not pool.grants and not pool.reservations
|
|
214
233
|
and now - pool.last_activity > self._retention):
|
|
215
234
|
del self._pools[key]
|
|
235
|
+
for pair in [p for p, k in self._jti_index.items() if k == key]:
|
|
236
|
+
del self._jti_index[pair]
|
|
216
237
|
return None
|
|
217
238
|
return pool
|
|
218
239
|
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _drained(pool: _Pool, jti: str) -> bool:
|
|
242
|
+
"""No request is in flight on this token any more."""
|
|
243
|
+
return not any(j == jti for j, _, _ in pool.reservations.values())
|
|
244
|
+
|
|
219
245
|
@staticmethod
|
|
220
246
|
def _remaining(pool: _Pool, jti: str) -> int:
|
|
221
247
|
"""The presented token's balance: its grant minus what was committed
|
|
@@ -251,14 +277,17 @@ class InMemoryMeter:
|
|
|
251
277
|
pool.last_activity = now
|
|
252
278
|
if jkt:
|
|
253
279
|
pool.jkt_of.setdefault(jti, jkt)
|
|
254
|
-
|
|
280
|
+
self._jti_index.setdefault((key[0], jti), key)
|
|
281
|
+
if jti not in pool.grants and wall_delta > 0 and jti not in pool.revoked:
|
|
255
282
|
pool.grants[jti] = (claim.amount, now + wall_delta)
|
|
256
283
|
|
|
257
284
|
async def reserve(self, key: MeterKey, jti: str,
|
|
258
|
-
max_cost: int) -> Reservation | InsufficientBudget:
|
|
285
|
+
max_cost: int) -> Reservation | InsufficientBudget | TokenRevoked:
|
|
259
286
|
async with self._lock:
|
|
260
287
|
now = time.monotonic()
|
|
261
288
|
pool = self._purge(key, now)
|
|
289
|
+
if pool is not None and jti in pool.revoked:
|
|
290
|
+
return TokenRevoked(drained=self._drained(pool, jti))
|
|
262
291
|
if pool is None or jti not in pool.grants:
|
|
263
292
|
return InsufficientBudget(remaining=0, exhausted=True)
|
|
264
293
|
remaining = self._remaining(pool, jti)
|
|
@@ -301,6 +330,73 @@ class InMemoryMeter:
|
|
|
301
330
|
pool = self._purge(key, time.monotonic())
|
|
302
331
|
return 0 if pool is None else self._remaining(pool, jti)
|
|
303
332
|
|
|
333
|
+
def _prune_revocations(self, wall: float) -> None:
|
|
334
|
+
for pair, exp in list(self._revocations.items()):
|
|
335
|
+
if wall > exp + self._revocation_skew:
|
|
336
|
+
del self._revocations[pair]
|
|
337
|
+
|
|
338
|
+
def _withdraw(self, iss: str, jti: str) -> bool:
|
|
339
|
+
"""Withdraw a known token's grant (call under lock). ``False`` when the
|
|
340
|
+
pair was never granted here — the revocation is still on record."""
|
|
341
|
+
key = self._jti_index.get((iss, jti))
|
|
342
|
+
if key is None:
|
|
343
|
+
return False
|
|
344
|
+
pool = self._purge(key, time.monotonic())
|
|
345
|
+
if pool is None:
|
|
346
|
+
return False
|
|
347
|
+
pool.revoked.setdefault(jti, time.time())
|
|
348
|
+
pool.grants.pop(jti, None)
|
|
349
|
+
pool.last_activity = time.monotonic()
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
async def record_revocation(self, iss: str, jti: str, exp: float) -> bool:
|
|
353
|
+
"""Record that ``iss`` revoked ``jti`` (-11 §11.12.1): remembered until
|
|
354
|
+
``exp`` plus clock skew whether or not the token was ever presented
|
|
355
|
+
here, so a later presentation is answered ``revoked_jwt``. If the token
|
|
356
|
+
holds a grant, it is withdrawn — no new request can reserve against it;
|
|
357
|
+
requests already in flight complete and are committed as usual.
|
|
358
|
+
Returns whether the token was known here. Idempotent."""
|
|
359
|
+
async with self._lock:
|
|
360
|
+
wall = time.time()
|
|
361
|
+
self._prune_revocations(wall)
|
|
362
|
+
if wall <= float(exp) + self._revocation_skew: # §11.12.1: past exp + skew is droppable
|
|
363
|
+
self._revocations[(iss, jti)] = float(exp)
|
|
364
|
+
return self._withdraw(iss, jti)
|
|
365
|
+
|
|
366
|
+
async def is_revoked(self, iss: str, jti: str) -> bool:
|
|
367
|
+
"""Whether ``(iss, jti)`` is on record as revoked — the hook for
|
|
368
|
+
:class:`HttpsigVerifier` (``is_revoked=meter.is_revoked``)."""
|
|
369
|
+
async with self._lock:
|
|
370
|
+
self._prune_revocations(time.time())
|
|
371
|
+
return (iss, jti) in self._revocations
|
|
372
|
+
|
|
373
|
+
async def revoke(self, iss: str, jti: str) -> bool:
|
|
374
|
+
"""Revoke a token this meter holds a grant for, by ``(iss, jti)``. Like
|
|
375
|
+
:meth:`record_revocation` with the grant's own ``exp``; ``False`` when
|
|
376
|
+
the pair is unknown here (nothing is recorded then — use
|
|
377
|
+
:meth:`record_revocation` when the issuer told you the exp)."""
|
|
378
|
+
async with self._lock:
|
|
379
|
+
key = self._jti_index.get((iss, jti))
|
|
380
|
+
if key is None:
|
|
381
|
+
return False
|
|
382
|
+
pool = self._purge(key, time.monotonic())
|
|
383
|
+
if pool is None:
|
|
384
|
+
return False
|
|
385
|
+
grant = pool.grants.get(jti)
|
|
386
|
+
wall = time.time()
|
|
387
|
+
exp = wall + max(0.0, grant[1] - time.monotonic()) if grant else wall
|
|
388
|
+
self._revocations[(iss, jti)] = max(self._revocations.get((iss, jti), 0.0), exp)
|
|
389
|
+
return self._withdraw(iss, jti)
|
|
390
|
+
|
|
391
|
+
async def revocation_state(self, key: MeterKey, jti: str) -> TokenRevoked | None:
|
|
392
|
+
"""``TokenRevoked`` (with its drain state) if the token was revoked
|
|
393
|
+
here, else ``None``."""
|
|
394
|
+
async with self._lock:
|
|
395
|
+
pool = self._purge(key, time.monotonic())
|
|
396
|
+
if pool is None or jti not in pool.revoked:
|
|
397
|
+
return None
|
|
398
|
+
return TokenRevoked(drained=self._drained(pool, jti))
|
|
399
|
+
|
|
304
400
|
def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
|
|
305
401
|
amount: int) -> None:
|
|
306
402
|
"""Post a committed cost to the usage counters (call under lock).
|
|
@@ -351,6 +447,10 @@ class InMemoryMeter:
|
|
|
351
447
|
pool = self._purge(key, time.monotonic())
|
|
352
448
|
if pool is None:
|
|
353
449
|
return None
|
|
450
|
+
if jti in pool.revoked and not self._drained(pool, jti):
|
|
451
|
+
# §Token Scope + AAuth #151: a revoked token's record is FINAL,
|
|
452
|
+
# so it is stated only once nothing is in flight on it.
|
|
453
|
+
return None
|
|
354
454
|
total = pool.consumed.get(jti, 0)
|
|
355
455
|
return {"jti": jti, "consumed": total} if total > 0 else None
|
|
356
456
|
|
regent_httpsig/config.py
CHANGED
|
@@ -5,22 +5,35 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
|
|
8
|
-
__all__ = ["HttpsigConfig"]
|
|
8
|
+
__all__ = ["AAUTH_REQUIRED_COMPONENTS", "HttpsigConfig"]
|
|
9
|
+
|
|
10
|
+
# AAuth -11 §11.3.3.1: what every agent signature MUST cover. A request with a
|
|
11
|
+
# body to a PS/AS/revocation endpoint additionally covers content-digest and
|
|
12
|
+
# content-type; the verifier adds those when it is handed the body.
|
|
13
|
+
AAUTH_REQUIRED_COMPONENTS: tuple[str, ...] = ("@method", "@authority", "@path", "signature-key")
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
@dataclass(frozen=True)
|
|
12
17
|
class HttpsigConfig:
|
|
13
18
|
"""Configuration for :class:`regent_httpsig.HttpsigVerifier`.
|
|
14
19
|
|
|
15
|
-
Defaults are production-safe
|
|
16
|
-
|
|
17
|
-
``
|
|
20
|
+
Defaults are production-safe and, on the AAuth path, follow -11 as
|
|
21
|
+
published: a 60-second ``created`` window, fully-specified algorithms only,
|
|
22
|
+
no clock-skew tolerance on ``exp``. Web Bot Auth keeps its own, looser age
|
|
23
|
+
rule (``max_age_hours``) because that profile defines no window."""
|
|
18
24
|
|
|
19
25
|
# Origins/issuers you additionally mark as trusted (``VerifiedSignature.trusted``).
|
|
20
26
|
# Verification itself never depends on this — it only annotates the result.
|
|
21
27
|
trusted_agents: frozenset[str] = field(default_factory=frozenset)
|
|
22
|
-
#
|
|
28
|
+
# Web Bot Auth only: reject signatures created earlier than this many hours ago.
|
|
23
29
|
max_age_hours: int = 25
|
|
30
|
+
# AAuth -11 §11.3.4 step 3: the signature validity window for `created`, in
|
|
31
|
+
# seconds. Older than the window → invalid_signature; further ahead of our
|
|
32
|
+
# clock → clock_skew. The same window bounds a token's `iat` (§11.5.2).
|
|
33
|
+
# Advertise a different value in resource metadata as `signature_window`.
|
|
34
|
+
signature_window_seconds: int = 60
|
|
35
|
+
# AAuth -11 §11.3.3.1: covered components every agent signature must include.
|
|
36
|
+
required_components: tuple[str, ...] = AAUTH_REQUIRED_COMPONENTS
|
|
24
37
|
# Key-directory cache TTL (seconds); failures are cached for negative_cache_ttl.
|
|
25
38
|
cache_ttl: float = 600.0
|
|
26
39
|
negative_cache_ttl: float = 120.0
|
|
@@ -31,17 +44,19 @@ class HttpsigConfig:
|
|
|
31
44
|
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
|
|
32
45
|
# e.g. frozenset({"localhost"})). Leave empty in production.
|
|
33
46
|
insecure_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
34
|
-
# AAuth -11
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
require_fully_specified_algs: bool =
|
|
47
|
+
# AAuth -11 §11.3.1: JOSE algs must be fully-specified (RFC 9864); the
|
|
48
|
+
# polymorphic "EdDSA" and keys without `alg` are refused. False is the
|
|
49
|
+
# pre-cut-over behaviour kept only for private test rigs; -11 forbids it.
|
|
50
|
+
require_fully_specified_algs: bool = True
|
|
38
51
|
# This service's public URL (e.g. "https://api.example"). Required to accept
|
|
39
|
-
# AAuth person tokens — their `aud` must name this resource.
|
|
40
|
-
#
|
|
52
|
+
# AAuth person tokens and auth tokens — their `aud` must name this resource.
|
|
53
|
+
# None disables both paths.
|
|
41
54
|
resource_url: str | None = None
|
|
42
|
-
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes)
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
55
|
+
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes) and
|
|
56
|
+
# signed server requests (the jwks_uri scheme, §11.3.2): issuer → JWKS URL
|
|
57
|
+
# for each Person Server / Access Server this resource accepts. A resource has
|
|
58
|
+
# an established relationship with its PS, so trust is pinned by
|
|
59
|
+
# configuration. The value may be "" to discover the JWKS through the
|
|
60
|
+
# issuer's metadata document ({id}/.well-known/{dwk}) instead of a fixed URL.
|
|
61
|
+
# Empty (the default) disables the auth-token and server paths entirely.
|
|
47
62
|
trusted_ps: Mapping[str, str] = field(default_factory=dict)
|