regent-httpsig 0.1.1__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 (24) hide show
  1. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/CHANGELOG.md +16 -0
  2. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/PKG-INFO +10 -1
  3. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/README.md +9 -0
  4. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/pyproject.toml +1 -1
  5. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/__init__.py +1 -1
  6. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/config.py +8 -0
  7. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/verify.py +76 -9
  8. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_verifier.py +109 -0
  9. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.github/workflows/ci.yml +0 -0
  10. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.github/workflows/release.yml +0 -0
  11. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.gitignore +0 -0
  12. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/LICENSE +0 -0
  13. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/SECURITY.md +0 -0
  14. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/examples/fastapi_verify.py +0 -0
  15. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/examples/httpx_signer.py +0 -0
  16. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/cli.py +0 -0
  17. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/fastapi.py +0 -0
  18. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/jwk.py +0 -0
  19. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/netguard.py +0 -0
  20. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/sfv.py +0 -0
  21. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/sign.py +0 -0
  22. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_netguard.py +0 -0
  23. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_signer.py +0 -0
  24. {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_vectors.py +0 -0
@@ -1,5 +1,21 @@
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
+
3
19
  ## 0.1.1
4
20
 
5
21
  - AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.1.1
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.
@@ -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
@@ -95,6 +101,9 @@ keyid-less shape is pinned in CI.
95
101
  - **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
96
102
  JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
97
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`).
98
107
  For a full-protocol AAuth implementation (both roles, all token types) see
99
108
  [christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
100
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.1"
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.1"
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
@@ -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("typ") != AAUTH_JWT_TYP or header.get("alg") in (None, "none"):
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") != "aauth-agent.json" or not iss.startswith("https://")
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
- # 1) Verify the agent_token against the issuer's published JWKS.
304
- 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)
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
- 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
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=["EdDSA", "ES256", "RS256"],
325
- 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
+ },
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="aauth",
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={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
+ },
367
434
  )
@@ -197,6 +197,115 @@ async def test_aauth_without_keyid_param(monkeypatch: pytest.MonkeyPatch) -> Non
197
197
  assert sig is not None and sig.scheme == "aauth" and sig.sub == "agent-42"
198
198
 
199
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
+
200
309
  async def test_cache_is_per_instance() -> None:
201
310
  a, b = HttpsigVerifier(), HttpsigVerifier()
202
311
  a._cache_put("https://x.example/doc", {"keys": []}, ttl=60)
File without changes