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.
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/CHANGELOG.md +23 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/PKG-INFO +18 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/README.md +17 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/pyproject.toml +1 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/__init__.py +1 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/config.py +8 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/verify.py +102 -10
- regent_httpsig-0.2.0/tests/test_verifier.py +314 -0
- regent_httpsig-0.1.0/tests/test_verifier.py +0 -150
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/.gitignore +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/LICENSE +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/SECURITY.md +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/fastapi.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/sfv.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.2.0}/tests/test_signer.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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("
|
|
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") !=
|
|
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
|
-
#
|
|
279
|
-
|
|
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
|
-
|
|
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=
|
|
300
|
-
|
|
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 =
|
|
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=
|
|
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={
|
|
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
|
|
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
|