regent-httpsig 0.1.1__py3-none-any.whl → 0.2.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 +1 -1
- regent_httpsig/config.py +8 -0
- regent_httpsig/verify.py +76 -9
- {regent_httpsig-0.1.1.dist-info → regent_httpsig-0.2.0.dist-info}/METADATA +10 -1
- {regent_httpsig-0.1.1.dist-info → regent_httpsig-0.2.0.dist-info}/RECORD +8 -8
- {regent_httpsig-0.1.1.dist-info → regent_httpsig-0.2.0.dist-info}/WHEEL +0 -0
- {regent_httpsig-0.1.1.dist-info → regent_httpsig-0.2.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.1.1.dist-info → regent_httpsig-0.2.0.dist-info}/licenses/LICENSE +0 -0
regent_httpsig/__init__.py
CHANGED
|
@@ -11,7 +11,7 @@ from regent_httpsig.sfv import parse_signature_agent
|
|
|
11
11
|
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
|
|
12
12
|
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
|
|
13
13
|
|
|
14
|
-
__version__ = "0.
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
"DIRECTORY_MEDIA_TYPE",
|
regent_httpsig/config.py
CHANGED
|
@@ -30,3 +30,11 @@ class HttpsigConfig:
|
|
|
30
30
|
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
|
|
31
31
|
# e.g. frozenset({"localhost"})). Leave empty in production.
|
|
32
32
|
insecure_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
33
|
+
# AAuth -11 (editor's copy): JOSE algs must be fully-specified per RFC 9864 —
|
|
34
|
+
# implementations MUST NOT accept the polymorphic "EdDSA". True enforces that;
|
|
35
|
+
# the False default keeps accepting "EdDSA" while the -10 ecosystem migrates.
|
|
36
|
+
require_fully_specified_algs: bool = False
|
|
37
|
+
# This service's public URL (e.g. "https://api.example"). Required to accept
|
|
38
|
+
# AAuth person tokens — their `aud` must name this resource. None disables
|
|
39
|
+
# the person-token path entirely.
|
|
40
|
+
resource_url: str | None = None
|
regent_httpsig/verify.py
CHANGED
|
@@ -55,7 +55,23 @@ logger = logging.getLogger("regent_httpsig")
|
|
|
55
55
|
WBA_TAG = "web-bot-auth"
|
|
56
56
|
WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
|
|
57
57
|
AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
|
|
58
|
+
AAUTH_PERSON_METADATA_PATH = "/.well-known/aauth-person.json"
|
|
58
59
|
AAUTH_JWT_TYP = "aa-agent+jwt"
|
|
60
|
+
AAUTH_PERSON_TYP = "aa-person+jwt"
|
|
61
|
+
# -11: a person token "lives at most one hour" — enforced with a small tolerance.
|
|
62
|
+
PERSON_TOKEN_MAX_LIFETIME = 3600 + 90
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _register_fully_specified_algs() -> None:
|
|
66
|
+
"""Register 'Ed25519' (RFC 9864 fully-specified) with PyJWT — same math as
|
|
67
|
+
the polymorphic 'EdDSA', which AAuth -11 forbids implementations to accept."""
|
|
68
|
+
import contextlib
|
|
69
|
+
|
|
70
|
+
import jwt as pyjwt
|
|
71
|
+
from jwt.algorithms import OKPAlgorithm
|
|
72
|
+
|
|
73
|
+
with contextlib.suppress(ValueError): # already registered = fine
|
|
74
|
+
pyjwt.register_algorithm("Ed25519", OKPAlgorithm())
|
|
59
75
|
|
|
60
76
|
|
|
61
77
|
@dataclass
|
|
@@ -286,22 +302,47 @@ class HttpsigVerifier:
|
|
|
286
302
|
return None
|
|
287
303
|
label, token = parsed
|
|
288
304
|
|
|
305
|
+
_register_fully_specified_algs()
|
|
289
306
|
try:
|
|
290
307
|
header = pyjwt.get_unverified_header(token)
|
|
291
308
|
unverified = pyjwt.decode(token, options={"verify_signature": False})
|
|
292
309
|
except Exception: # noqa: BLE001
|
|
293
310
|
return None
|
|
294
|
-
if header.get("
|
|
311
|
+
if header.get("alg") in (None, "none"):
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
# -11 token-type dispatch: agent tokens (identity mode) and person tokens
|
|
315
|
+
# (PS-issued, per-resource, opt-in via config.resource_url).
|
|
316
|
+
typ = header.get("typ")
|
|
317
|
+
if typ == AAUTH_JWT_TYP:
|
|
318
|
+
scheme, expected_dwk = "aauth", "aauth-agent.json"
|
|
319
|
+
metadata_path, audience = AAUTH_METADATA_PATH, None
|
|
320
|
+
elif typ == AAUTH_PERSON_TYP:
|
|
321
|
+
if not self.config.resource_url:
|
|
322
|
+
logger.info("person token presented but config.resource_url is not "
|
|
323
|
+
"set — person-token verification is disabled")
|
|
324
|
+
return None
|
|
325
|
+
scheme, expected_dwk = "aauth-person", "aauth-person.json"
|
|
326
|
+
metadata_path, audience = AAUTH_PERSON_METADATA_PATH, self.config.resource_url
|
|
327
|
+
else:
|
|
295
328
|
return None
|
|
329
|
+
|
|
296
330
|
iss = str(unverified.get("iss", ""))
|
|
297
|
-
bad_iss = unverified.get("dwk") !=
|
|
331
|
+
bad_iss = unverified.get("dwk") != expected_dwk or not iss.startswith("https://")
|
|
298
332
|
if bad_iss and not (
|
|
299
333
|
iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
|
|
300
334
|
):
|
|
301
335
|
return None
|
|
302
336
|
|
|
303
|
-
#
|
|
304
|
-
|
|
337
|
+
# AAuth -11 / RFC 9864: fully-specified algorithms. "EdDSA" (polymorphic)
|
|
338
|
+
# is accepted only while require_fully_specified_algs is False — a
|
|
339
|
+
# transition affordance for the -10 ecosystem.
|
|
340
|
+
allowed_algs = ["Ed25519", "ES256", "RS256"]
|
|
341
|
+
if not self.config.require_fully_specified_algs:
|
|
342
|
+
allowed_algs.append("EdDSA")
|
|
343
|
+
|
|
344
|
+
# 1) Verify the token against the issuer's published JWKS.
|
|
345
|
+
metadata = await self._fetch_json(iss.rstrip("/") + metadata_path)
|
|
305
346
|
if not metadata or not metadata.get("jwks_uri"):
|
|
306
347
|
return None
|
|
307
348
|
jwks = await self._fetch_json(str(metadata["jwks_uri"]))
|
|
@@ -314,24 +355,46 @@ class HttpsigVerifier:
|
|
|
314
355
|
issuer_key = pyjwt.PyJWK(k).key
|
|
315
356
|
break
|
|
316
357
|
except Exception: # noqa: BLE001
|
|
317
|
-
|
|
358
|
+
# PyJWK's internal registry predates RFC 9864 names — a JWKS
|
|
359
|
+
# advertising alg "Ed25519" is valid in -11 but unknown to it.
|
|
360
|
+
try:
|
|
361
|
+
issuer_key = load_ed25519_jwk(k)
|
|
362
|
+
break
|
|
363
|
+
except ValueError:
|
|
364
|
+
continue
|
|
318
365
|
if issuer_key is None:
|
|
319
366
|
return None
|
|
320
367
|
try:
|
|
321
368
|
claims = pyjwt.decode(
|
|
322
369
|
token,
|
|
323
370
|
key=issuer_key,
|
|
324
|
-
algorithms=
|
|
325
|
-
|
|
371
|
+
algorithms=allowed_algs,
|
|
372
|
+
audience=audience,
|
|
373
|
+
options={
|
|
374
|
+
"require": ["iss", "sub", "exp", "iat"],
|
|
375
|
+
"verify_aud": audience is not None,
|
|
376
|
+
},
|
|
326
377
|
)
|
|
327
378
|
except Exception as exc: # noqa: BLE001
|
|
328
379
|
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
|
|
329
380
|
return None
|
|
330
381
|
|
|
382
|
+
# -11: a person token "lives at most one hour".
|
|
383
|
+
if typ == AAUTH_PERSON_TYP:
|
|
384
|
+
lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
|
|
385
|
+
if lifetime <= 0 or lifetime > PERSON_TOKEN_MAX_LIFETIME:
|
|
386
|
+
logger.info("person token lifetime %ss out of bounds iss=%s", lifetime, iss)
|
|
387
|
+
return None
|
|
388
|
+
|
|
331
389
|
# 2) Proof of possession: the request signature must verify against cnf.jwk.
|
|
332
390
|
cnf_jwk = (claims.get("cnf") or {}).get("jwk")
|
|
333
391
|
if not isinstance(cnf_jwk, dict):
|
|
334
392
|
return None
|
|
393
|
+
# -11 strict mode: the cnf JWK "MUST carry a fully-specified alg member".
|
|
394
|
+
if self.config.require_fully_specified_algs and cnf_jwk.get("alg") != "Ed25519":
|
|
395
|
+
logger.info("cnf.jwk alg %r is not fully-specified iss=%s",
|
|
396
|
+
cnf_jwk.get("alg"), iss)
|
|
397
|
+
return None
|
|
335
398
|
try:
|
|
336
399
|
pop_key = load_ed25519_jwk(cnf_jwk)
|
|
337
400
|
except ValueError:
|
|
@@ -357,11 +420,15 @@ class HttpsigVerifier:
|
|
|
357
420
|
return None
|
|
358
421
|
|
|
359
422
|
return VerifiedSignature(
|
|
360
|
-
scheme=
|
|
423
|
+
scheme=scheme,
|
|
361
424
|
agent=iss,
|
|
362
425
|
keyid=jwk_thumbprint(cnf_jwk),
|
|
363
426
|
trusted=iss in self.config.trusted_agents,
|
|
364
427
|
sub=str(claims.get("sub", "")),
|
|
365
428
|
label=label,
|
|
366
|
-
claims={
|
|
429
|
+
claims={
|
|
430
|
+
k: claims[k]
|
|
431
|
+
for k in ("iss", "sub", "exp", "ps", "aud", "jti", "mission_s256")
|
|
432
|
+
if k in claims
|
|
433
|
+
},
|
|
367
434
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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,6 +131,9 @@ 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.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
regent_httpsig/__init__.py,sha256=
|
|
1
|
+
regent_httpsig/__init__.py,sha256=9TWY0NncSbLGXq4mosCKEB1SGKHcIPOpYgAyOzPp_7Y,987
|
|
2
2
|
regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
|
|
3
|
-
regent_httpsig/config.py,sha256=
|
|
3
|
+
regent_httpsig/config.py,sha256=dGWzZtS5DhCr59ecR3scX-JdHD4iZ4XFcioejz2KXDA,1954
|
|
4
4
|
regent_httpsig/fastapi.py,sha256=YmfrKdeMft2vpuiRlivIEHYC4BIs3ioR7T3t1ZEzcJ4,3707
|
|
5
5
|
regent_httpsig/jwk.py,sha256=h4vgnIgpnyKeWdWpm7g_hEyXZOPVREWn-bD4JN07tT0,1637
|
|
6
6
|
regent_httpsig/netguard.py,sha256=Sqg08mdC94RWvnQLJaH4weZW0nN2VlTJF5wVF8VYGHY,2121
|
|
7
7
|
regent_httpsig/sfv.py,sha256=kcQsBLw9h4h_6D3z2rv0-3_gLZQYC47B9VZi-oS_Vgk,4975
|
|
8
8
|
regent_httpsig/sign.py,sha256=B5ChxFxuuKL0i10bGrgFImzyh8GMjWIqNDECXehBWTA,4397
|
|
9
|
-
regent_httpsig/verify.py,sha256=
|
|
10
|
-
regent_httpsig-0.
|
|
11
|
-
regent_httpsig-0.
|
|
12
|
-
regent_httpsig-0.
|
|
13
|
-
regent_httpsig-0.
|
|
14
|
-
regent_httpsig-0.
|
|
9
|
+
regent_httpsig/verify.py,sha256=nMSOc1hY29X4SW2g9SD9LwLXt6ladBI7-iUU-1bYpwA,18461
|
|
10
|
+
regent_httpsig-0.2.0.dist-info/METADATA,sha256=76bz3NCU5pNPZfPWg-URUmeFvJjqOd-vIlpM03aFXEU,9129
|
|
11
|
+
regent_httpsig-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
regent_httpsig-0.2.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
|
|
13
|
+
regent_httpsig-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
+
regent_httpsig-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|