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.
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/CHANGELOG.md +16 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/PKG-INFO +10 -1
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/README.md +9 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/pyproject.toml +1 -1
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/__init__.py +1 -1
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/config.py +8 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/verify.py +76 -9
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_verifier.py +109 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/.gitignore +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/LICENSE +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/SECURITY.md +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/fastapi.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/sfv.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.1.1 → regent_httpsig-0.2.0}/tests/test_signer.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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("
|
|
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") !=
|
|
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
|
-
#
|
|
304
|
-
|
|
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
|
-
|
|
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=
|
|
325
|
-
|
|
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=
|
|
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={
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|