regent-httpsig 0.1.0__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 +102 -10
- {regent_httpsig-0.1.0.dist-info → regent_httpsig-0.2.0.dist-info}/METADATA +18 -1
- {regent_httpsig-0.1.0.dist-info → regent_httpsig-0.2.0.dist-info}/RECORD +8 -8
- {regent_httpsig-0.1.0.dist-info → regent_httpsig-0.2.0.dist-info}/WHEEL +0 -0
- {regent_httpsig-0.1.0.dist-info → regent_httpsig-0.2.0.dist-info}/entry_points.txt +0 -0
- {regent_httpsig-0.1.0.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
|
|
@@ -84,6 +100,31 @@ class VerifiedSignature:
|
|
|
84
100
|
return out
|
|
85
101
|
|
|
86
102
|
|
|
103
|
+
class _KeyidOptionalParams(dict): # type: ignore[type-arg]
|
|
104
|
+
"""RFC 9421 makes ``keyid`` OPTIONAL, but the upstream verifier reads
|
|
105
|
+
``params["keyid"]`` unconditionally. On the AAuth path the key comes from
|
|
106
|
+
the token's ``cnf.jwk``, so conforming signers (e.g. aauth-signing) omit
|
|
107
|
+
keyid entirely. Returning None for a missing keyid routes resolution to our
|
|
108
|
+
StaticKeyResolver default WITHOUT adding the key to the params — iteration
|
|
109
|
+
is unchanged, so the reconstructed signature base stays byte-identical."""
|
|
110
|
+
|
|
111
|
+
def __getitem__(self, key: str) -> Any:
|
|
112
|
+
if key == "keyid" and key not in self:
|
|
113
|
+
return None
|
|
114
|
+
return super().__getitem__(key)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _KeyidOptionalVerifier(HTTPMessageVerifier):
|
|
118
|
+
def _verify_one(self, *, label: Any, sig_input: Any, signature: Any,
|
|
119
|
+
message: Any, max_age: Any) -> Any:
|
|
120
|
+
if "keyid" not in sig_input.params:
|
|
121
|
+
sig_input.params = _KeyidOptionalParams(sig_input.params)
|
|
122
|
+
return super()._verify_one( # type: ignore[no-untyped-call]
|
|
123
|
+
label=label, sig_input=sig_input, signature=signature,
|
|
124
|
+
message=message, max_age=max_age,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
87
128
|
def _keys_from_jwks(doc: dict[str, Any]) -> dict[str, Ed25519PublicKey]:
|
|
88
129
|
keys: dict[str, Ed25519PublicKey] = {}
|
|
89
130
|
for jwk in list(doc.get("keys") or [])[:10]:
|
|
@@ -261,22 +302,47 @@ class HttpsigVerifier:
|
|
|
261
302
|
return None
|
|
262
303
|
label, token = parsed
|
|
263
304
|
|
|
305
|
+
_register_fully_specified_algs()
|
|
264
306
|
try:
|
|
265
307
|
header = pyjwt.get_unverified_header(token)
|
|
266
308
|
unverified = pyjwt.decode(token, options={"verify_signature": False})
|
|
267
309
|
except Exception: # noqa: BLE001
|
|
268
310
|
return None
|
|
269
|
-
if header.get("
|
|
311
|
+
if header.get("alg") in (None, "none"):
|
|
270
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:
|
|
328
|
+
return None
|
|
329
|
+
|
|
271
330
|
iss = str(unverified.get("iss", ""))
|
|
272
|
-
bad_iss = unverified.get("dwk") !=
|
|
331
|
+
bad_iss = unverified.get("dwk") != expected_dwk or not iss.startswith("https://")
|
|
273
332
|
if bad_iss and not (
|
|
274
333
|
iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
|
|
275
334
|
):
|
|
276
335
|
return None
|
|
277
336
|
|
|
278
|
-
#
|
|
279
|
-
|
|
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)
|
|
280
346
|
if not metadata or not metadata.get("jwks_uri"):
|
|
281
347
|
return None
|
|
282
348
|
jwks = await self._fetch_json(str(metadata["jwks_uri"]))
|
|
@@ -289,29 +355,51 @@ class HttpsigVerifier:
|
|
|
289
355
|
issuer_key = pyjwt.PyJWK(k).key
|
|
290
356
|
break
|
|
291
357
|
except Exception: # noqa: BLE001
|
|
292
|
-
|
|
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
|
|
293
365
|
if issuer_key is None:
|
|
294
366
|
return None
|
|
295
367
|
try:
|
|
296
368
|
claims = pyjwt.decode(
|
|
297
369
|
token,
|
|
298
370
|
key=issuer_key,
|
|
299
|
-
algorithms=
|
|
300
|
-
|
|
371
|
+
algorithms=allowed_algs,
|
|
372
|
+
audience=audience,
|
|
373
|
+
options={
|
|
374
|
+
"require": ["iss", "sub", "exp", "iat"],
|
|
375
|
+
"verify_aud": audience is not None,
|
|
376
|
+
},
|
|
301
377
|
)
|
|
302
378
|
except Exception as exc: # noqa: BLE001
|
|
303
379
|
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
|
|
304
380
|
return None
|
|
305
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
|
+
|
|
306
389
|
# 2) Proof of possession: the request signature must verify against cnf.jwk.
|
|
307
390
|
cnf_jwk = (claims.get("cnf") or {}).get("jwk")
|
|
308
391
|
if not isinstance(cnf_jwk, dict):
|
|
309
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
|
|
310
398
|
try:
|
|
311
399
|
pop_key = load_ed25519_jwk(cnf_jwk)
|
|
312
400
|
except ValueError:
|
|
313
401
|
return None
|
|
314
|
-
verifier =
|
|
402
|
+
verifier = _KeyidOptionalVerifier(
|
|
315
403
|
signature_algorithm=ED25519,
|
|
316
404
|
key_resolver=StaticKeyResolver({}, default=pop_key),
|
|
317
405
|
component_resolver_class=DictKeyComponentResolver,
|
|
@@ -332,11 +420,15 @@ class HttpsigVerifier:
|
|
|
332
420
|
return None
|
|
333
421
|
|
|
334
422
|
return VerifiedSignature(
|
|
335
|
-
scheme=
|
|
423
|
+
scheme=scheme,
|
|
336
424
|
agent=iss,
|
|
337
425
|
keyid=jwk_thumbprint(cnf_jwk),
|
|
338
426
|
trusted=iss in self.config.trusted_agents,
|
|
339
427
|
sub=str(claims.get("sub", "")),
|
|
340
428
|
label=label,
|
|
341
|
-
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
|
+
},
|
|
342
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
|
|
@@ -101,6 +107,7 @@ and every Web Bot Auth verifier on the internet can now identify your agent.
|
|
|
101
107
|
| Web Bot Auth A.2.3 — legacy sf-string form (**what OpenAI ships in production**) | ✅ in CI |
|
|
102
108
|
| Sign → verify roundtrip (fresh keys, full pipeline) | ✅ in CI |
|
|
103
109
|
| AAuth identity-mode roundtrip (`aa-agent+jwt` + `cnf.jwk` proof of possession) | ✅ in CI |
|
|
110
|
+
| Signed by [`aauth-signing`](https://github.com/christian-posta/aauth-python-library) (jwt scheme, keyid-less) → verified | ✅² |
|
|
104
111
|
| Tampered request / expired signature / wrong directory key rejected | ✅ in CI |
|
|
105
112
|
|
|
106
113
|
¹ The signature bytes printed in the draft's own A.2.2 example do **not** verify over the
|
|
@@ -108,6 +115,13 @@ draft's own signature base (the legacy A.2.3 vector and RFC 9421 B.2.6 both do,
|
|
|
108
115
|
is in the example, not the canonicalization). Ed25519 is deterministic, so our test pins the
|
|
109
116
|
vector re-signed with the same RFC test key over the same byte-exact base — reported upstream.
|
|
110
117
|
|
|
118
|
+
² Cross-library interop with `aauth-signing`'s jwt scheme: token layer, `cnf.jwk` proof of
|
|
119
|
+
possession and canonicalization all verify. Its signers correctly omit the optional `keyid`
|
|
120
|
+
parameter — which exposed an unconditional `keyid` read in the underlying RFC 9421 library
|
|
121
|
+
that we now handle. One deviation reported upstream to `aauth-signing`: it emits the
|
|
122
|
+
`Signature` byte sequence as base64url, while RFC 8941 requires standard base64. The
|
|
123
|
+
keyid-less shape is pinned in CI.
|
|
124
|
+
|
|
111
125
|
## Both dialects, one verifier
|
|
112
126
|
|
|
113
127
|
- **Web Bot Auth** (`draft-meunier-web-bot-auth-architecture`): key discovery via
|
|
@@ -117,6 +131,9 @@ vector re-signed with the same RFC test key over the same byte-exact base — re
|
|
|
117
131
|
- **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
|
|
118
132
|
JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
|
|
119
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`).
|
|
120
137
|
For a full-protocol AAuth implementation (both roles, all token types) see
|
|
121
138
|
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
|
|
122
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
|