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.
Files changed (24) hide show
  1. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/CHANGELOG.md +7 -0
  2. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/PKG-INFO +9 -1
  3. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/README.md +8 -0
  4. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/pyproject.toml +1 -1
  5. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/__init__.py +1 -1
  6. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/verify.py +26 -1
  7. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_verifier.py +55 -0
  8. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.github/workflows/ci.yml +0 -0
  9. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.github/workflows/release.yml +0 -0
  10. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/.gitignore +0 -0
  11. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/LICENSE +0 -0
  12. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/SECURITY.md +0 -0
  13. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/examples/fastapi_verify.py +0 -0
  14. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/examples/httpx_signer.py +0 -0
  15. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/cli.py +0 -0
  16. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/config.py +0 -0
  17. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/fastapi.py +0 -0
  18. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/jwk.py +0 -0
  19. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/netguard.py +0 -0
  20. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/sfv.py +0 -0
  21. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/src/regent_httpsig/sign.py +0 -0
  22. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_netguard.py +0 -0
  23. {regent_httpsig-0.1.0 → regent_httpsig-0.1.1}/tests/test_signer.py +0 -0
  24. {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.0
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.0"
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.0"
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 = HTTPMessageVerifier(
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