regent-httpsig 0.1.0__tar.gz → 0.1.1__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.1.1}/CHANGELOG.md +7 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/PKG-INFO +9 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/README.md +8 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/pyproject.toml +1 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/__init__.py +1 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/verify.py +26 -1
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_verifier.py +55 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.gitignore +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/LICENSE +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/SECURITY.md +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/config.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/fastapi.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/sfv.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_signer.py +0 -0
- {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_vectors.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
|
|
6
|
+
the token's `cnf.jwk`). Exposed by cross-library interop with
|
|
7
|
+
christian-posta/aauth-signing, whose signers correctly omit it; that signer's
|
|
8
|
+
exact keyid-less shape is now pinned in CI.
|
|
9
|
+
|
|
3
10
|
## 0.1.0
|
|
4
11
|
|
|
5
12
|
Initial release, extracted from Regent Protocol's production marketplace
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
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
|
|
@@ -101,6 +101,7 @@ and every Web Bot Auth verifier on the internet can now identify your agent.
|
|
|
101
101
|
| Web Bot Auth A.2.3 — legacy sf-string form (**what OpenAI ships in production**) | ✅ in CI |
|
|
102
102
|
| Sign → verify roundtrip (fresh keys, full pipeline) | ✅ in CI |
|
|
103
103
|
| AAuth identity-mode roundtrip (`aa-agent+jwt` + `cnf.jwk` proof of possession) | ✅ in CI |
|
|
104
|
+
| Signed by [`aauth-signing`](https://github.com/christian-posta/aauth-python-library) (jwt scheme, keyid-less) → verified | ✅² |
|
|
104
105
|
| Tampered request / expired signature / wrong directory key rejected | ✅ in CI |
|
|
105
106
|
|
|
106
107
|
¹ The signature bytes printed in the draft's own A.2.2 example do **not** verify over the
|
|
@@ -108,6 +109,13 @@ draft's own signature base (the legacy A.2.3 vector and RFC 9421 B.2.6 both do,
|
|
|
108
109
|
is in the example, not the canonicalization). Ed25519 is deterministic, so our test pins the
|
|
109
110
|
vector re-signed with the same RFC test key over the same byte-exact base — reported upstream.
|
|
110
111
|
|
|
112
|
+
² Cross-library interop with `aauth-signing`'s jwt scheme: token layer, `cnf.jwk` proof of
|
|
113
|
+
possession and canonicalization all verify. Its signers correctly omit the optional `keyid`
|
|
114
|
+
parameter — which exposed an unconditional `keyid` read in the underlying RFC 9421 library
|
|
115
|
+
that we now handle. One deviation reported upstream to `aauth-signing`: it emits the
|
|
116
|
+
`Signature` byte sequence as base64url, while RFC 8941 requires standard base64. The
|
|
117
|
+
keyid-less shape is pinned in CI.
|
|
118
|
+
|
|
111
119
|
## Both dialects, one verifier
|
|
112
120
|
|
|
113
121
|
- **Web Bot Auth** (`draft-meunier-web-bot-auth-architecture`): key discovery via
|
|
@@ -71,6 +71,7 @@ and every Web Bot Auth verifier on the internet can now identify your agent.
|
|
|
71
71
|
| Web Bot Auth A.2.3 — legacy sf-string form (**what OpenAI ships in production**) | ✅ in CI |
|
|
72
72
|
| Sign → verify roundtrip (fresh keys, full pipeline) | ✅ in CI |
|
|
73
73
|
| AAuth identity-mode roundtrip (`aa-agent+jwt` + `cnf.jwk` proof of possession) | ✅ in CI |
|
|
74
|
+
| Signed by [`aauth-signing`](https://github.com/christian-posta/aauth-python-library) (jwt scheme, keyid-less) → verified | ✅² |
|
|
74
75
|
| Tampered request / expired signature / wrong directory key rejected | ✅ in CI |
|
|
75
76
|
|
|
76
77
|
¹ The signature bytes printed in the draft's own A.2.2 example do **not** verify over the
|
|
@@ -78,6 +79,13 @@ draft's own signature base (the legacy A.2.3 vector and RFC 9421 B.2.6 both do,
|
|
|
78
79
|
is in the example, not the canonicalization). Ed25519 is deterministic, so our test pins the
|
|
79
80
|
vector re-signed with the same RFC test key over the same byte-exact base — reported upstream.
|
|
80
81
|
|
|
82
|
+
² Cross-library interop with `aauth-signing`'s jwt scheme: token layer, `cnf.jwk` proof of
|
|
83
|
+
possession and canonicalization all verify. Its signers correctly omit the optional `keyid`
|
|
84
|
+
parameter — which exposed an unconditional `keyid` read in the underlying RFC 9421 library
|
|
85
|
+
that we now handle. One deviation reported upstream to `aauth-signing`: it emits the
|
|
86
|
+
`Signature` byte sequence as base64url, while RFC 8941 requires standard base64. The
|
|
87
|
+
keyid-less shape is pinned in CI.
|
|
88
|
+
|
|
81
89
|
## Both dialects, one verifier
|
|
82
90
|
|
|
83
91
|
- **Web Bot Auth** (`draft-meunier-web-bot-auth-architecture`): key discovery via
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-httpsig"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
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.
|
|
14
|
+
__version__ = "0.1.1"
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
"DIRECTORY_MEDIA_TYPE",
|
|
@@ -84,6 +84,31 @@ class VerifiedSignature:
|
|
|
84
84
|
return out
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
class _KeyidOptionalParams(dict): # type: ignore[type-arg]
|
|
88
|
+
"""RFC 9421 makes ``keyid`` OPTIONAL, but the upstream verifier reads
|
|
89
|
+
``params["keyid"]`` unconditionally. On the AAuth path the key comes from
|
|
90
|
+
the token's ``cnf.jwk``, so conforming signers (e.g. aauth-signing) omit
|
|
91
|
+
keyid entirely. Returning None for a missing keyid routes resolution to our
|
|
92
|
+
StaticKeyResolver default WITHOUT adding the key to the params — iteration
|
|
93
|
+
is unchanged, so the reconstructed signature base stays byte-identical."""
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, key: str) -> Any:
|
|
96
|
+
if key == "keyid" and key not in self:
|
|
97
|
+
return None
|
|
98
|
+
return super().__getitem__(key)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _KeyidOptionalVerifier(HTTPMessageVerifier):
|
|
102
|
+
def _verify_one(self, *, label: Any, sig_input: Any, signature: Any,
|
|
103
|
+
message: Any, max_age: Any) -> Any:
|
|
104
|
+
if "keyid" not in sig_input.params:
|
|
105
|
+
sig_input.params = _KeyidOptionalParams(sig_input.params)
|
|
106
|
+
return super()._verify_one( # type: ignore[no-untyped-call]
|
|
107
|
+
label=label, sig_input=sig_input, signature=signature,
|
|
108
|
+
message=message, max_age=max_age,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
87
112
|
def _keys_from_jwks(doc: dict[str, Any]) -> dict[str, Ed25519PublicKey]:
|
|
88
113
|
keys: dict[str, Ed25519PublicKey] = {}
|
|
89
114
|
for jwk in list(doc.get("keys") or [])[:10]:
|
|
@@ -311,7 +336,7 @@ class HttpsigVerifier:
|
|
|
311
336
|
pop_key = load_ed25519_jwk(cnf_jwk)
|
|
312
337
|
except ValueError:
|
|
313
338
|
return None
|
|
314
|
-
verifier =
|
|
339
|
+
verifier = _KeyidOptionalVerifier(
|
|
315
340
|
signature_algorithm=ED25519,
|
|
316
341
|
key_resolver=StaticKeyResolver({}, default=pop_key),
|
|
317
342
|
component_resolver_class=DictKeyComponentResolver,
|
|
@@ -142,6 +142,61 @@ async def test_aauth_expired_token_rejected(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
142
142
|
assert await verifier.verify("POST", url, headers) is None
|
|
143
143
|
|
|
144
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
|
+
|
|
145
200
|
async def test_cache_is_per_instance() -> None:
|
|
146
201
|
a, b = HttpsigVerifier(), HttpsigVerifier()
|
|
147
202
|
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
|
|
File without changes
|