regent-httpsig 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/CHANGELOG.md +23 -0
  2. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/PKG-INFO +18 -1
  3. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/README.md +17 -0
  4. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/pyproject.toml +1 -1
  5. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/__init__.py +1 -1
  6. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/config.py +8 -0
  7. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/verify.py +102 -10
  8. regent_httpsig-0.2.0/tests/test_verifier.py +314 -0
  9. regent_httpsig-0.1.0/tests/test_verifier.py +0 -150
  10. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.github/workflows/ci.yml +0 -0
  11. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.github/workflows/release.yml +0 -0
  12. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.gitignore +0 -0
  13. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/LICENSE +0 -0
  14. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/SECURITY.md +0 -0
  15. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/examples/fastapi_verify.py +0 -0
  16. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/examples/httpx_signer.py +0 -0
  17. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/cli.py +0 -0
  18. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/fastapi.py +0 -0
  19. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/jwk.py +0 -0
  20. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/netguard.py +0 -0
  21. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/sfv.py +0 -0
  22. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/sign.py +0 -0
  23. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/tests/test_netguard.py +0 -0
  24. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/tests/test_signer.py +0 -0
  25. {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/tests/test_vectors.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ AAuth draft **-11** support (per the editor's copy, ahead of datatracker publication):
6
+
7
+ - **Fully-specified algorithms (RFC 9864):** `Ed25519` accepted everywhere
8
+ (registered with PyJWT, including JWKS entries PyJWK cannot parse).
9
+ New `HttpsigConfig.require_fully_specified_algs` enforces the -11 MUST NOT on
10
+ the polymorphic `EdDSA`; the default keeps accepting it while the -10
11
+ ecosystem migrates, and will flip when -11 posts.
12
+ - **Person tokens** (`typ: aa-person+jwt`): PS-issued, per-resource `aud`,
13
+ `cnf`-bound, ≤1h lifetime — verified via `{iss}/.well-known/aauth-person.json`.
14
+ Opt-in: set `HttpsigConfig.resource_url` (the token's `aud` must name it).
15
+ Result scheme: `"aauth-person"`, `sub` = the PS's directed user identifier.
16
+ - Strict mode also enforces the -11 requirement that `cnf.jwk` carries a
17
+ fully-specified `alg` member.
18
+
19
+ ## 0.1.1
20
+
21
+ - AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
22
+ the token's `cnf.jwk`). Exposed by cross-library interop with
23
+ christian-posta/aauth-signing, whose signers correctly omit it; that signer's
24
+ exact keyid-less shape is now pinned in CI.
25
+
3
26
  ## 0.1.0
4
27
 
5
28
  Initial release, extracted from Regent Protocol's production marketplace
@@ -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.
@@ -42,6 +42,12 @@ signature yields `None`, and nothing ever raises on untrusted input. Use
42
42
  `regent_httpsig.fastapi.RequiredSignatureDep` when a signature must be present — the 401
43
43
  tells the agent exactly how to sign.
44
44
 
45
+ > **Behind a reverse proxy?** The agent signed the *public* URL
46
+ > (`https://api.example/…`), but your ASGI server sees `http://container/…`. The FastAPI
47
+ > dependency rebuilds the signed URL from `X-Forwarded-Proto` + `Host`, so make sure your
48
+ > proxy forwards the scheme — nginx: `proxy_set_header X-Forwarded-Proto $scheme;`.
49
+ > If signatures mysteriously fail to verify in production, check this first.
50
+
45
51
  ## Sign: get your agent past bot walls
46
52
 
47
53
  ```python
@@ -71,6 +77,7 @@ and every Web Bot Auth verifier on the internet can now identify your agent.
71
77
  | Web Bot Auth A.2.3 — legacy sf-string form (**what OpenAI ships in production**) | ✅ in CI |
72
78
  | Sign → verify roundtrip (fresh keys, full pipeline) | ✅ in CI |
73
79
  | AAuth identity-mode roundtrip (`aa-agent+jwt` + `cnf.jwk` proof of possession) | ✅ in CI |
80
+ | Signed by [`aauth-signing`](https://github.com/christian-posta/aauth-python-library) (jwt scheme, keyid-less) → verified | ✅² |
74
81
  | Tampered request / expired signature / wrong directory key rejected | ✅ in CI |
75
82
 
76
83
  ¹ The signature bytes printed in the draft's own A.2.2 example do **not** verify over the
@@ -78,6 +85,13 @@ draft's own signature base (the legacy A.2.3 vector and RFC 9421 B.2.6 both do,
78
85
  is in the example, not the canonicalization). Ed25519 is deterministic, so our test pins the
79
86
  vector re-signed with the same RFC test key over the same byte-exact base — reported upstream.
80
87
 
88
+ ² Cross-library interop with `aauth-signing`'s jwt scheme: token layer, `cnf.jwk` proof of
89
+ possession and canonicalization all verify. Its signers correctly omit the optional `keyid`
90
+ parameter — which exposed an unconditional `keyid` read in the underlying RFC 9421 library
91
+ that we now handle. One deviation reported upstream to `aauth-signing`: it emits the
92
+ `Signature` byte sequence as base64url, while RFC 8941 requires standard base64. The
93
+ keyid-less shape is pinned in CI.
94
+
81
95
  ## Both dialects, one verifier
82
96
 
83
97
  - **Web Bot Auth** (`draft-meunier-web-bot-auth-architecture`): key discovery via
@@ -87,6 +101,9 @@ vector re-signed with the same RFC test key over the same byte-exact base — re
87
101
  - **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
88
102
  JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
89
103
  `cnf.jwk` verifies the request signature. Install with `pip install 'regent-httpsig[aauth]'`.
104
+ Tracks the **-11 editor's copy**: fully-specified algorithms (RFC 9864, `Ed25519` — with a
105
+ transition flag for the -10 ecosystem's `EdDSA`) and **person tokens** (`aa-person+jwt`,
106
+ opt-in via `HttpsigConfig.resource_url`).
90
107
  For a full-protocol AAuth implementation (both roles, all token types) see
91
108
  [christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
92
109
  this library is the thin relying-party verifier that handles both dialects.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "regent-httpsig"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -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",
@@ -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
@@ -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
  )
@@ -0,0 +1,314 @@
1
+ """End-to-end HttpsigVerifier tests — directory fetching mocked, crypto real."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
8
+
9
+ import jwt as pyjwt
10
+ import pytest
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
+
13
+ from regent_httpsig import EgressSigner, HttpsigConfig, HttpsigVerifier, generate_seed
14
+ from regent_httpsig.jwk import b64url
15
+
16
+
17
+ def _mock_fetch(mapping: dict[str, dict[str, Any]]):
18
+ async def fetch(url: str) -> dict[str, Any] | None:
19
+ return mapping.get(url)
20
+
21
+ return fetch
22
+
23
+
24
+ async def test_no_signature_header_is_none() -> None:
25
+ verifier = HttpsigVerifier()
26
+ assert await verifier.verify("GET", "https://api.example/x", {}) is None
27
+
28
+
29
+ async def test_wba_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ """EgressSigner output verifies through the full verifier pipeline."""
31
+ signer = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
32
+ url = "https://api.example/v1/orders?limit=5"
33
+ headers = signer.sign("POST", url, {"Host": "api.example"})
34
+
35
+ verifier = HttpsigVerifier(HttpsigConfig(trusted_agents=frozenset({"https://agent.example"})))
36
+ monkeypatch.setattr(
37
+ verifier, "_fetch_json",
38
+ _mock_fetch({
39
+ "https://agent.example/.well-known/http-message-signatures-directory":
40
+ signer.directory(),
41
+ }),
42
+ )
43
+ sig = await verifier.verify("POST", url, headers)
44
+ assert sig is not None
45
+ assert sig.scheme == "web-bot-auth"
46
+ assert sig.agent == "https://agent.example"
47
+ assert sig.keyid == signer.keyid
48
+ assert sig.trusted is True
49
+ assert sig.context()["signed_agent"] is True
50
+
51
+
52
+ async def test_wba_wrong_directory_key_fails(monkeypatch: pytest.MonkeyPatch) -> None:
53
+ signer = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
54
+ other = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
55
+ url = "https://api.example/v1/orders"
56
+ headers = signer.sign("POST", url, {"Host": "api.example"})
57
+
58
+ verifier = HttpsigVerifier()
59
+ monkeypatch.setattr(
60
+ verifier, "_fetch_json",
61
+ _mock_fetch({
62
+ "https://agent.example/.well-known/http-message-signatures-directory":
63
+ other.directory(), # directory publishes a DIFFERENT key
64
+ }),
65
+ )
66
+ assert await verifier.verify("POST", url, headers) is None
67
+
68
+
69
+ async def test_aauth_identity_mode_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
70
+ """AAuth identity-based mode: agent_token verified against issuer JWKS, request
71
+ signature verified against the token's cnf.jwk (proof of possession)."""
72
+ issuer_priv = Ed25519PrivateKey.generate()
73
+ issuer_jwk = {
74
+ "kty": "OKP", "crv": "Ed25519", "kid": "iss-1", "alg": "EdDSA",
75
+ "x": b64url(issuer_priv.public_key().public_bytes_raw()),
76
+ }
77
+ agent_signer = EgressSigner(seed=generate_seed(), signature_agent="https://issuer.example")
78
+
79
+ now = int(time.time())
80
+ token = pyjwt.encode(
81
+ {
82
+ "iss": "https://issuer.example", "sub": "agent-42",
83
+ "iat": now, "exp": now + 600, "dwk": "aauth-agent.json",
84
+ "cnf": {"jwk": agent_signer.public_jwk},
85
+ },
86
+ issuer_priv,
87
+ algorithm="EdDSA",
88
+ headers={"typ": "aa-agent+jwt", "kid": "iss-1"},
89
+ )
90
+
91
+ url = "https://api.example/v1/orders"
92
+ headers = agent_signer.sign("POST", url, {"Host": "api.example"})
93
+ headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
94
+
95
+ verifier = HttpsigVerifier()
96
+ monkeypatch.setattr(
97
+ verifier, "_fetch_json",
98
+ _mock_fetch({
99
+ "https://issuer.example/.well-known/aauth-agent.json":
100
+ {"jwks_uri": "https://issuer.example/jwks.json"},
101
+ "https://issuer.example/jwks.json": {"keys": [issuer_jwk]},
102
+ }),
103
+ )
104
+ sig = await verifier.verify("POST", url, headers)
105
+ assert sig is not None
106
+ assert sig.scheme == "aauth"
107
+ assert sig.agent == "https://issuer.example"
108
+ assert sig.sub == "agent-42"
109
+ assert sig.keyid == agent_signer.keyid
110
+
111
+
112
+ async def test_aauth_expired_token_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
113
+ issuer_priv = Ed25519PrivateKey.generate()
114
+ issuer_jwk = {
115
+ "kty": "OKP", "crv": "Ed25519", "kid": "iss-1", "alg": "EdDSA",
116
+ "x": b64url(issuer_priv.public_key().public_bytes_raw()),
117
+ }
118
+ agent_signer = EgressSigner(seed=generate_seed(), signature_agent="https://issuer.example")
119
+ expired = int((datetime.now(UTC) - timedelta(hours=2)).timestamp())
120
+ token = pyjwt.encode(
121
+ {
122
+ "iss": "https://issuer.example", "sub": "agent-42",
123
+ "iat": expired, "exp": expired + 60, "dwk": "aauth-agent.json",
124
+ "cnf": {"jwk": agent_signer.public_jwk},
125
+ },
126
+ issuer_priv, algorithm="EdDSA",
127
+ headers={"typ": "aa-agent+jwt", "kid": "iss-1"},
128
+ )
129
+ url = "https://api.example/v1/orders"
130
+ headers = agent_signer.sign("POST", url, {"Host": "api.example"})
131
+ headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
132
+
133
+ verifier = HttpsigVerifier()
134
+ monkeypatch.setattr(
135
+ verifier, "_fetch_json",
136
+ _mock_fetch({
137
+ "https://issuer.example/.well-known/aauth-agent.json":
138
+ {"jwks_uri": "https://issuer.example/jwks.json"},
139
+ "https://issuer.example/jwks.json": {"keys": [issuer_jwk]},
140
+ }),
141
+ )
142
+ assert await verifier.verify("POST", url, headers) is None
143
+
144
+
145
+ async def test_aauth_without_keyid_param(monkeypatch: pytest.MonkeyPatch) -> None:
146
+ """RFC 9421 makes keyid OPTIONAL; on the AAuth path the key comes from the
147
+ token's cnf.jwk, so conforming signers (e.g. christian-posta/aauth-signing)
148
+ omit it. Pins the interop shape: signature built WITHOUT keyid must verify."""
149
+ import base64
150
+
151
+ agent_priv = Ed25519PrivateKey.generate()
152
+ agent_jwk = {"kty": "OKP", "crv": "Ed25519",
153
+ "x": b64url(agent_priv.public_key().public_bytes_raw())}
154
+ issuer_priv = Ed25519PrivateKey.generate()
155
+ issuer_jwk = {
156
+ "kty": "OKP", "crv": "Ed25519", "kid": "iss-1", "alg": "EdDSA",
157
+ "x": b64url(issuer_priv.public_key().public_bytes_raw()),
158
+ }
159
+ now = int(time.time())
160
+ token = pyjwt.encode(
161
+ {"iss": "https://issuer.example", "sub": "agent-42", "iat": now,
162
+ "exp": now + 600, "dwk": "aauth-agent.json", "cnf": {"jwk": agent_jwk}},
163
+ issuer_priv, algorithm="EdDSA",
164
+ headers={"typ": "aa-agent+jwt", "kid": "iss-1"},
165
+ )
166
+
167
+ # Build the signature exactly the way aauth-signing does: covered components
168
+ # @method/@authority/@path/signature-key, created only — NO keyid param.
169
+ sig_key_header = f'sig=jwt;jwt="{token}"'
170
+ params = ('("@method" "@authority" "@path" "signature-key")'
171
+ f";created={now}")
172
+ base = "\n".join([
173
+ '"@method": POST',
174
+ '"@authority": api.example',
175
+ '"@path": /v1/orders',
176
+ f'"signature-key": {sig_key_header}',
177
+ f'"@signature-params": {params}',
178
+ ]).encode()
179
+ signature = base64.b64encode(agent_priv.sign(base)).decode()
180
+ headers = {
181
+ "Host": "api.example",
182
+ "Signature-Key": sig_key_header,
183
+ "Signature-Input": f"sig={params}",
184
+ "Signature": f"sig=:{signature}:",
185
+ }
186
+
187
+ verifier = HttpsigVerifier()
188
+ monkeypatch.setattr(
189
+ verifier, "_fetch_json",
190
+ _mock_fetch({
191
+ "https://issuer.example/.well-known/aauth-agent.json":
192
+ {"jwks_uri": "https://issuer.example/jwks.json"},
193
+ "https://issuer.example/jwks.json": {"keys": [issuer_jwk]},
194
+ }),
195
+ )
196
+ sig = await verifier.verify("POST", "https://api.example/v1/orders", headers)
197
+ assert sig is not None and sig.scheme == "aauth" and sig.sub == "agent-42"
198
+
199
+
200
+ def _issuer_pair(kid: str = "iss-1", alg: str = "EdDSA"):
201
+ priv = Ed25519PrivateKey.generate()
202
+ jwk = {"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": alg,
203
+ "x": b64url(priv.public_key().public_bytes_raw())}
204
+ return priv, jwk
205
+
206
+
207
+ def _mint(issuer_priv, *, typ: str, alg: str, claims: dict) -> str:
208
+ from regent_httpsig.verify import _register_fully_specified_algs
209
+
210
+ _register_fully_specified_algs()
211
+ return pyjwt.encode(claims, issuer_priv, algorithm=alg,
212
+ headers={"typ": typ, "kid": "iss-1"})
213
+
214
+
215
+ class TestFullySpecifiedAlgs:
216
+ """AAuth -11 / RFC 9864: Ed25519 accepted; polymorphic EdDSA gated by config."""
217
+
218
+ async def _roundtrip(self, alg: str, config: HttpsigConfig,
219
+ monkeypatch: pytest.MonkeyPatch):
220
+ issuer_priv, issuer_jwk = _issuer_pair(alg=alg)
221
+ agent = EgressSigner(seed=generate_seed(), signature_agent="https://issuer.example")
222
+ now = int(time.time())
223
+ token = _mint(issuer_priv, typ="aa-agent+jwt", alg=alg, claims={
224
+ "iss": "https://issuer.example", "sub": "a-1", "iat": now, "exp": now + 600,
225
+ "dwk": "aauth-agent.json",
226
+ "cnf": {"jwk": {**agent.public_jwk, "alg": "Ed25519"}},
227
+ })
228
+ url = "https://api.example/v1/x"
229
+ headers = agent.sign("POST", url, {"Host": "api.example"})
230
+ headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
231
+ verifier = HttpsigVerifier(config)
232
+ monkeypatch.setattr(verifier, "_fetch_json", _mock_fetch({
233
+ "https://issuer.example/.well-known/aauth-agent.json":
234
+ {"jwks_uri": "https://issuer.example/j"},
235
+ "https://issuer.example/j": {"keys": [issuer_jwk]},
236
+ }))
237
+ return await verifier.verify("POST", url, headers)
238
+
239
+ async def test_ed25519_fully_specified_verifies(self, monkeypatch) -> None:
240
+ sig = await self._roundtrip("Ed25519", HttpsigConfig(), monkeypatch)
241
+ assert sig is not None and sig.scheme == "aauth"
242
+
243
+ async def test_eddsa_accepted_in_transition_mode(self, monkeypatch) -> None:
244
+ sig = await self._roundtrip("EdDSA", HttpsigConfig(), monkeypatch)
245
+ assert sig is not None # default: -10 ecosystem still accepted
246
+
247
+ async def test_eddsa_rejected_in_strict_mode(self, monkeypatch) -> None:
248
+ strict = HttpsigConfig(require_fully_specified_algs=True)
249
+ assert await self._roundtrip("EdDSA", strict, monkeypatch) is None
250
+
251
+ async def test_ed25519_verifies_in_strict_mode(self, monkeypatch) -> None:
252
+ strict = HttpsigConfig(require_fully_specified_algs=True)
253
+ sig = await self._roundtrip("Ed25519", strict, monkeypatch)
254
+ assert sig is not None
255
+
256
+
257
+ class TestPersonTokens:
258
+ """AAuth -11 person tokens: PS-issued, per-resource aud, cnf-bound, ≤1h."""
259
+
260
+ def _headers(self, *, aud: str, lifetime: int = 600):
261
+ ps_priv, ps_jwk = _issuer_pair()
262
+ agent = EgressSigner(seed=generate_seed(), signature_agent="https://ps.example")
263
+ now = int(time.time())
264
+ token = _mint(ps_priv, typ="aa-person+jwt", alg="Ed25519", claims={
265
+ "iss": "https://ps.example", "sub": "directed-sub-1", "aud": aud,
266
+ "iat": now, "exp": now + lifetime, "dwk": "aauth-person.json",
267
+ "jti": "pt-1", "cnf": {"jwk": {**agent.public_jwk, "alg": "Ed25519"}},
268
+ })
269
+ url = "https://api.example/v1/x"
270
+ headers = agent.sign("POST", url, {"Host": "api.example"})
271
+ headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
272
+ return url, headers, ps_jwk
273
+
274
+ def _verifier(self, ps_jwk, monkeypatch, **cfg):
275
+ verifier = HttpsigVerifier(HttpsigConfig(**cfg))
276
+ monkeypatch.setattr(verifier, "_fetch_json", _mock_fetch({
277
+ "https://ps.example/.well-known/aauth-person.json":
278
+ {"jwks_uri": "https://ps.example/j"},
279
+ "https://ps.example/j": {"keys": [ps_jwk]},
280
+ }))
281
+ return verifier
282
+
283
+ async def test_person_token_roundtrip(self, monkeypatch) -> None:
284
+ url, headers, ps_jwk = self._headers(aud="https://api.example")
285
+ v = self._verifier(ps_jwk, monkeypatch, resource_url="https://api.example")
286
+ sig = await v.verify("POST", url, headers)
287
+ assert sig is not None
288
+ assert sig.scheme == "aauth-person"
289
+ assert sig.agent == "https://ps.example" # the PS, not the agent operator
290
+ assert sig.sub == "directed-sub-1"
291
+ assert sig.claims.get("jti") == "pt-1"
292
+
293
+ async def test_person_token_wrong_audience_rejected(self, monkeypatch) -> None:
294
+ url, headers, ps_jwk = self._headers(aud="https://OTHER.example")
295
+ v = self._verifier(ps_jwk, monkeypatch, resource_url="https://api.example")
296
+ assert await v.verify("POST", url, headers) is None
297
+
298
+ async def test_person_token_disabled_without_resource_url(self, monkeypatch) -> None:
299
+ url, headers, ps_jwk = self._headers(aud="https://api.example")
300
+ v = self._verifier(ps_jwk, monkeypatch) # no resource_url → path disabled
301
+ assert await v.verify("POST", url, headers) is None
302
+
303
+ async def test_person_token_overlong_lifetime_rejected(self, monkeypatch) -> None:
304
+ url, headers, ps_jwk = self._headers(aud="https://api.example", lifetime=7200)
305
+ v = self._verifier(ps_jwk, monkeypatch, resource_url="https://api.example")
306
+ assert await v.verify("POST", url, headers) is None
307
+
308
+
309
+ async def test_cache_is_per_instance() -> None:
310
+ a, b = HttpsigVerifier(), HttpsigVerifier()
311
+ a._cache_put("https://x.example/doc", {"keys": []}, ttl=60)
312
+ hit_a, _ = a._cache_get("https://x.example/doc")
313
+ hit_b, _ = b._cache_get("https://x.example/doc")
314
+ assert hit_a is True and hit_b is False
@@ -1,150 +0,0 @@
1
- """End-to-end HttpsigVerifier tests — directory fetching mocked, crypto real."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from datetime import UTC, datetime, timedelta
7
- from typing import Any
8
-
9
- import jwt as pyjwt
10
- import pytest
11
- from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
-
13
- from regent_httpsig import EgressSigner, HttpsigConfig, HttpsigVerifier, generate_seed
14
- from regent_httpsig.jwk import b64url
15
-
16
-
17
- def _mock_fetch(mapping: dict[str, dict[str, Any]]):
18
- async def fetch(url: str) -> dict[str, Any] | None:
19
- return mapping.get(url)
20
-
21
- return fetch
22
-
23
-
24
- async def test_no_signature_header_is_none() -> None:
25
- verifier = HttpsigVerifier()
26
- assert await verifier.verify("GET", "https://api.example/x", {}) is None
27
-
28
-
29
- async def test_wba_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
30
- """EgressSigner output verifies through the full verifier pipeline."""
31
- signer = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
32
- url = "https://api.example/v1/orders?limit=5"
33
- headers = signer.sign("POST", url, {"Host": "api.example"})
34
-
35
- verifier = HttpsigVerifier(HttpsigConfig(trusted_agents=frozenset({"https://agent.example"})))
36
- monkeypatch.setattr(
37
- verifier, "_fetch_json",
38
- _mock_fetch({
39
- "https://agent.example/.well-known/http-message-signatures-directory":
40
- signer.directory(),
41
- }),
42
- )
43
- sig = await verifier.verify("POST", url, headers)
44
- assert sig is not None
45
- assert sig.scheme == "web-bot-auth"
46
- assert sig.agent == "https://agent.example"
47
- assert sig.keyid == signer.keyid
48
- assert sig.trusted is True
49
- assert sig.context()["signed_agent"] is True
50
-
51
-
52
- async def test_wba_wrong_directory_key_fails(monkeypatch: pytest.MonkeyPatch) -> None:
53
- signer = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
54
- other = EgressSigner(seed=generate_seed(), signature_agent="https://agent.example")
55
- url = "https://api.example/v1/orders"
56
- headers = signer.sign("POST", url, {"Host": "api.example"})
57
-
58
- verifier = HttpsigVerifier()
59
- monkeypatch.setattr(
60
- verifier, "_fetch_json",
61
- _mock_fetch({
62
- "https://agent.example/.well-known/http-message-signatures-directory":
63
- other.directory(), # directory publishes a DIFFERENT key
64
- }),
65
- )
66
- assert await verifier.verify("POST", url, headers) is None
67
-
68
-
69
- async def test_aauth_identity_mode_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
70
- """AAuth identity-based mode: agent_token verified against issuer JWKS, request
71
- signature verified against the token's cnf.jwk (proof of possession)."""
72
- issuer_priv = Ed25519PrivateKey.generate()
73
- issuer_jwk = {
74
- "kty": "OKP", "crv": "Ed25519", "kid": "iss-1", "alg": "EdDSA",
75
- "x": b64url(issuer_priv.public_key().public_bytes_raw()),
76
- }
77
- agent_signer = EgressSigner(seed=generate_seed(), signature_agent="https://issuer.example")
78
-
79
- now = int(time.time())
80
- token = pyjwt.encode(
81
- {
82
- "iss": "https://issuer.example", "sub": "agent-42",
83
- "iat": now, "exp": now + 600, "dwk": "aauth-agent.json",
84
- "cnf": {"jwk": agent_signer.public_jwk},
85
- },
86
- issuer_priv,
87
- algorithm="EdDSA",
88
- headers={"typ": "aa-agent+jwt", "kid": "iss-1"},
89
- )
90
-
91
- url = "https://api.example/v1/orders"
92
- headers = agent_signer.sign("POST", url, {"Host": "api.example"})
93
- headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
94
-
95
- verifier = HttpsigVerifier()
96
- monkeypatch.setattr(
97
- verifier, "_fetch_json",
98
- _mock_fetch({
99
- "https://issuer.example/.well-known/aauth-agent.json":
100
- {"jwks_uri": "https://issuer.example/jwks.json"},
101
- "https://issuer.example/jwks.json": {"keys": [issuer_jwk]},
102
- }),
103
- )
104
- sig = await verifier.verify("POST", url, headers)
105
- assert sig is not None
106
- assert sig.scheme == "aauth"
107
- assert sig.agent == "https://issuer.example"
108
- assert sig.sub == "agent-42"
109
- assert sig.keyid == agent_signer.keyid
110
-
111
-
112
- async def test_aauth_expired_token_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
113
- issuer_priv = Ed25519PrivateKey.generate()
114
- issuer_jwk = {
115
- "kty": "OKP", "crv": "Ed25519", "kid": "iss-1", "alg": "EdDSA",
116
- "x": b64url(issuer_priv.public_key().public_bytes_raw()),
117
- }
118
- agent_signer = EgressSigner(seed=generate_seed(), signature_agent="https://issuer.example")
119
- expired = int((datetime.now(UTC) - timedelta(hours=2)).timestamp())
120
- token = pyjwt.encode(
121
- {
122
- "iss": "https://issuer.example", "sub": "agent-42",
123
- "iat": expired, "exp": expired + 60, "dwk": "aauth-agent.json",
124
- "cnf": {"jwk": agent_signer.public_jwk},
125
- },
126
- issuer_priv, algorithm="EdDSA",
127
- headers={"typ": "aa-agent+jwt", "kid": "iss-1"},
128
- )
129
- url = "https://api.example/v1/orders"
130
- headers = agent_signer.sign("POST", url, {"Host": "api.example"})
131
- headers["Signature-Key"] = f'sig1=jwt;jwt="{token}"'
132
-
133
- verifier = HttpsigVerifier()
134
- monkeypatch.setattr(
135
- verifier, "_fetch_json",
136
- _mock_fetch({
137
- "https://issuer.example/.well-known/aauth-agent.json":
138
- {"jwks_uri": "https://issuer.example/jwks.json"},
139
- "https://issuer.example/jwks.json": {"keys": [issuer_jwk]},
140
- }),
141
- )
142
- assert await verifier.verify("POST", url, headers) is None
143
-
144
-
145
- async def test_cache_is_per_instance() -> None:
146
- a, b = HttpsigVerifier(), HttpsigVerifier()
147
- a._cache_put("https://x.example/doc", {"keys": []}, ttl=60)
148
- hit_a, _ = a._cache_get("https://x.example/doc")
149
- hit_b, _ = b._cache_get("https://x.example/doc")
150
- assert hit_a is True and hit_b is False
File without changes