Skip to content

Commit ee7d32a

Browse files
committed
0.2.0: AAuth -11 support — fully-specified algs (RFC 9864) + person tokens
Per the -11 editor's copy: Ed25519 as the fully-specified JOSE alg (with a require_fully_specified_algs transition flag gating the polymorphic EdDSA), JWKS fallback for alg names PyJWK's registry predates, aa-person+jwt verification (PS discovery via aauth-person.json, per-resource aud, cnf PoP, 1h lifetime cap), strict-mode cnf.jwk alg enforcement.
1 parent fed47f7 commit ee7d32a

7 files changed

Lines changed: 214 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

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+
319
## 0.1.1
420

521
- AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ keyid-less shape is pinned in CI.
101101
- **AAuth** (`draft-hardt-oauth-aauth-protocol`, identity-based mode): the agent carries a
102102
JWT `agent_token` in `Signature-Key`; the issuer's JWKS verifies the token, the token's
103103
`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`).
104107
For a full-protocol AAuth implementation (both roles, all token types) see
105108
[christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library)
106109
this library is the thin relying-party verifier that handles both dialects.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "regent-httpsig"
7-
version = "0.1.1"
7+
version = "0.2.0"
88
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/regent_httpsig/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
1212
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
1313

14-
__version__ = "0.1.1"
14+
__version__ = "0.2.0"
1515

1616
__all__ = [
1717
"DIRECTORY_MEDIA_TYPE",

src/regent_httpsig/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ class HttpsigConfig:
3030
# Hosts exempt from the https-only + public-IP SSRF guard (local dev only —
3131
# e.g. frozenset({"localhost"})). Leave empty in production.
3232
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

src/regent_httpsig/verify.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,23 @@
5555
WBA_TAG = "web-bot-auth"
5656
WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
5757
AAUTH_METADATA_PATH = "/.well-known/aauth-agent.json"
58+
AAUTH_PERSON_METADATA_PATH = "/.well-known/aauth-person.json"
5859
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())
5975

6076

6177
@dataclass
@@ -286,22 +302,47 @@ async def _verify_aauth(
286302
return None
287303
label, token = parsed
288304

305+
_register_fully_specified_algs()
289306
try:
290307
header = pyjwt.get_unverified_header(token)
291308
unverified = pyjwt.decode(token, options={"verify_signature": False})
292309
except Exception: # noqa: BLE001
293310
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:
295328
return None
329+
296330
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://")
298332
if bad_iss and not (
299333
iss and urlsplit(iss).hostname in self.config.insecure_hosts # dev escape
300334
):
301335
return None
302336

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)
305346
if not metadata or not metadata.get("jwks_uri"):
306347
return None
307348
jwks = await self._fetch_json(str(metadata["jwks_uri"]))
@@ -314,24 +355,46 @@ async def _verify_aauth(
314355
issuer_key = pyjwt.PyJWK(k).key
315356
break
316357
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
318365
if issuer_key is None:
319366
return None
320367
try:
321368
claims = pyjwt.decode(
322369
token,
323370
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+
},
326377
)
327378
except Exception as exc: # noqa: BLE001
328379
logger.info("aauth token invalid iss=%s: %s", iss, str(exc)[:200])
329380
return None
330381

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+
331389
# 2) Proof of possession: the request signature must verify against cnf.jwk.
332390
cnf_jwk = (claims.get("cnf") or {}).get("jwk")
333391
if not isinstance(cnf_jwk, dict):
334392
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
335398
try:
336399
pop_key = load_ed25519_jwk(cnf_jwk)
337400
except ValueError:
@@ -357,11 +420,15 @@ async def _verify_aauth(
357420
return None
358421

359422
return VerifiedSignature(
360-
scheme="aauth",
423+
scheme=scheme,
361424
agent=iss,
362425
keyid=jwk_thumbprint(cnf_jwk),
363426
trusted=iss in self.config.trusted_agents,
364427
sub=str(claims.get("sub", "")),
365428
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+
},
367434
)

tests/test_verifier.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,115 @@ async def test_aauth_without_keyid_param(monkeypatch: pytest.MonkeyPatch) -> Non
197197
assert sig is not None and sig.scheme == "aauth" and sig.sub == "agent-42"
198198

199199

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+
200309
async def test_cache_is_per_instance() -> None:
201310
a, b = HttpsigVerifier(), HttpsigVerifier()
202311
a._cache_put("https://x.example/doc", {"keys": []}, ttl=60)

0 commit comments

Comments
 (0)