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.
@@ -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.sfv import (
26
- build_aauth_budget_header,
27
- build_aauth_requirement,
28
- parse_signature_agent,
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.3.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
- self._key_usage: dict[tuple[str, str], tuple[int, float]] = {} # (iss, jkt) -> (total, last_wall)
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
- if jti not in pool.grants and wall_delta > 0:
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: https-only directory fetching over public IPs,
16
- bounded cache, 25-hour max signature age (Web Bot Auth signatures carry
17
- ``created``/``expires``; 25h tolerates a day of clock drift)."""
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
- # Reject signatures created earlier than this many hours ago.
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 (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
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. None disables
40
- # the person-token path entirely.
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
- # 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.
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)