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.
@@ -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.1.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("typ") != AAUTH_JWT_TYP or header.get("alg") in (None, "none"):
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") != "aauth-agent.json" or not iss.startswith("https://")
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
- # 1) Verify the agent_token against the issuer's published JWKS.
279
- metadata = await self._fetch_json(iss.rstrip("/") + AAUTH_METADATA_PATH)
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
- continue
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=["EdDSA", "ES256", "RS256"],
300
- options={"require": ["iss", "sub", "exp", "iat"]},
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 = HTTPMessageVerifier(
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="aauth",
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={k: claims[k] for k in ("iss", "sub", "exp", "ps") if k in 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.1.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=UZ-RmE0KLmqjD7JV7LnSOnPTc5FPO-5OtQzvsYq9PAs,987
1
+ regent_httpsig/__init__.py,sha256=9TWY0NncSbLGXq4mosCKEB1SGKHcIPOpYgAyOzPp_7Y,987
2
2
  regent_httpsig/cli.py,sha256=WUSLQ2WuEX4E85L2C2EgoUfu5cWpj6MRSG1UVNHpzcw,2005
3
- regent_httpsig/config.py,sha256=xkILlWugaD8Nn4huAgL6RP-C916KxCYRn0YpqCkMOGc,1422
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=YVcYWqxUBmqSdWrW8nBCSceT7YApAUjO5J-FIMKBRVM,14256
10
- regent_httpsig-0.1.0.dist-info/METADATA,sha256=4v_RgOudkaT3ui9921kZhM8R5fGfJbLl5e3e1pDBTh0,7871
11
- regent_httpsig-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
- regent_httpsig-0.1.0.dist-info/entry_points.txt,sha256=SgZdmc27V14IAmwOjbBoCYLsbrALaqdTUHNHLbil2dU,59
13
- regent_httpsig-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
14
- regent_httpsig-0.1.0.dist-info/RECORD,,
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,,