Skip to content

Commit 4de648e

Browse files
bokelleyclaude
andauthored
feat(signing): SigningProvider Protocol for KMS-backed signing (#283) (#323)
* feat(signing): SigningProvider Protocol for KMS-backed signing (#283) Decouples the RFC 9421 request-signing profile from in-process key storage so KMS / HSM / Vault deployments can plug in without forking the signer or pulling private material out of the managed store at boot. * `SigningProvider` Protocol — async `sign(signature_base) -> bytes`, `key_id() -> str`, `algorithm() -> Literal["ed25519","ecdsa-p256-sha256"]`. Contract docstring covers lazy init, public-key type-checking, rotation tripwire, key separation by `adcp_use`, and fingerprint redaction. * `InMemorySigningProvider` — default adapter; validates Ed25519 vs. EC, EC curve = SECP256R1, and key_id non-empty at construction. * `async_sign_request(provider=...)` — async entry point sharing the canonicalization spine (`_prepare_signature`) with the sync `sign_request`, so both paths produce byte-identical Signature-Input. * `pem_to_adcp_jwk(pem, *, kid, purpose, password=None)` — derives the public JWK for KMS adopters whose private half never leaves the managed store. Accepts both PKCS#8 private and SPKI public PEMs. * RFC 8941 §3.3.3 escaping fix at `signer.py:_escape_sf_string` — applies to `keyid`, `nonce`, `tag`. Rejects characters outside printable ASCII 0x20-0x7E to close header-injection / parser- divergence vectors at non-httpx integrators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(signing): validate sig label as RFC 8941 sf-key (#283) The `label` kwarg on `sign_request` / `async_sign_request` is a public input that lands unquoted in both the `Signature-Input` and `Signature` headers. Without validation, a CRLF or other non-token character in the label would inject extra header bytes (at non-httpx integrators that don't sanitize embedded line terminators) or produce a label that conformant verifiers parse differently from this serializer. Add `_validate_sf_key()` enforcing the RFC 8941 §3.1.2 token grammar: must start with `[a-z*]`, then `[a-z0-9_\-.*]`. Applied at the entry of `_prepare_signature` so both sync and async signers are covered. Same parser-divergence / header-injection class as the `keyid`/`nonce`/`tag` escaping fix already in this PR — just for the remaining unguarded input. Also adds the SPKI-public-key RSA rejection test for `pem_to_adcp_jwk` that was missing from the previous coverage round. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d3e1a0f commit 4de648e

5 files changed

Lines changed: 1038 additions & 45 deletions

File tree

src/adcp/signing/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,17 @@
181181
verify_detached_jws,
182182
verify_jws_document,
183183
)
184-
from adcp.signing.keygen import generate_signing_keypair
184+
from adcp.signing.keygen import generate_signing_keypair, pem_to_adcp_jwk
185185
from adcp.signing.middleware import (
186186
unauthorized_response_headers,
187187
verify_flask_request,
188188
verify_starlette_request,
189189
)
190+
from adcp.signing.provider import (
191+
InMemorySigningProvider,
192+
SigningAlgorithm,
193+
SigningProvider,
194+
)
190195
from adcp.signing.replay import InMemoryReplayStore, ReplayStore
191196
from adcp.signing.revocation import RevocationChecker, RevocationList
192197
from adcp.signing.revocation_fetcher import (
@@ -205,6 +210,7 @@
205210
)
206211
from adcp.signing.signer import (
207212
SignedHeaders,
213+
async_sign_request,
208214
sign_request,
209215
)
210216
from adcp.signing.verifier import (
@@ -259,6 +265,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
259265
"DEFAULT_TAG",
260266
"FetchResult",
261267
"InMemoryReplayStore",
268+
"InMemorySigningProvider",
262269
"IpPinnedTransport",
263270
"JwksResolver",
264271
"JwsError",
@@ -299,8 +306,10 @@ def __init__(self, *args: object, **kwargs: object) -> None:
299306
"SignatureInputLabel",
300307
"SignatureVerificationError",
301308
"SignedHeaders",
309+
"SigningAlgorithm",
302310
"SigningConfig",
303311
"SigningDecision",
312+
"SigningProvider",
304313
"StaticJwksResolver",
305314
"VerifiedSigner",
306315
"VerifierCapability",
@@ -310,6 +319,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
310319
"as_async_resolver",
311320
"async_default_jwks_fetcher",
312321
"async_default_revocation_list_fetcher",
322+
"async_sign_request",
313323
"averify_detached_jws",
314324
"averify_jws_document",
315325
"b64url_decode",
@@ -330,6 +340,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
330340
"load_private_key_pem",
331341
"operation_needs_signing",
332342
"parse_signature_input_header",
343+
"pem_to_adcp_jwk",
333344
"private_key_from_jwk",
334345
"public_key_from_jwk",
335346
"resolve_and_validate_host",

src/adcp/signing/keygen.py

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from cryptography.hazmat.primitives import serialization
5454
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
5555

56-
from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_encode
56+
from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_encode, load_private_key_pem
5757

5858

5959
def _encryption_algorithm(
@@ -67,21 +67,14 @@ def _encryption_algorithm(
6767
_ADCP_USE_VALUES = ("request-signing", "webhook-signing")
6868

6969

70-
def generate_ed25519(
71-
kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing"
72-
) -> tuple[bytes, dict[str, Any]]:
73-
private = ed25519.Ed25519PrivateKey.generate()
74-
pem = private.private_bytes(
75-
encoding=serialization.Encoding.PEM,
76-
format=serialization.PrivateFormat.PKCS8,
77-
encryption_algorithm=_encryption_algorithm(passphrase),
78-
)
79-
public = private.public_key()
80-
x = public.public_bytes(
70+
def _public_jwk_ed25519(
71+
public_key: ed25519.Ed25519PublicKey, *, kid: str, adcp_use: str
72+
) -> dict[str, Any]:
73+
x = public_key.public_bytes(
8174
encoding=serialization.Encoding.Raw,
8275
format=serialization.PublicFormat.Raw,
8376
)
84-
jwk = {
77+
return {
8578
"kty": "OKP",
8679
"crv": "Ed25519",
8780
"alg": "EdDSA",
@@ -91,20 +84,13 @@ def generate_ed25519(
9184
"kid": kid,
9285
"x": b64url_encode(x),
9386
}
94-
return pem, jwk
9587

9688

97-
def generate_es256(
98-
kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing"
99-
) -> tuple[bytes, dict[str, Any]]:
100-
private = ec.generate_private_key(ec.SECP256R1())
101-
pem = private.private_bytes(
102-
encoding=serialization.Encoding.PEM,
103-
format=serialization.PrivateFormat.PKCS8,
104-
encryption_algorithm=_encryption_algorithm(passphrase),
105-
)
106-
numbers = private.public_key().public_numbers()
107-
jwk = {
89+
def _public_jwk_es256(
90+
public_key: ec.EllipticCurvePublicKey, *, kid: str, adcp_use: str
91+
) -> dict[str, Any]:
92+
numbers = public_key.public_numbers()
93+
return {
10894
"kty": "EC",
10995
"crv": "P-256",
11096
"alg": "ES256",
@@ -115,9 +101,117 @@ def generate_es256(
115101
"x": b64url_encode(numbers.x.to_bytes(32, "big")),
116102
"y": b64url_encode(numbers.y.to_bytes(32, "big")),
117103
}
104+
105+
106+
def generate_ed25519(
107+
kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing"
108+
) -> tuple[bytes, dict[str, Any]]:
109+
private = ed25519.Ed25519PrivateKey.generate()
110+
pem = private.private_bytes(
111+
encoding=serialization.Encoding.PEM,
112+
format=serialization.PrivateFormat.PKCS8,
113+
encryption_algorithm=_encryption_algorithm(passphrase),
114+
)
115+
jwk = _public_jwk_ed25519(private.public_key(), kid=kid, adcp_use=adcp_use)
116+
return pem, jwk
117+
118+
119+
def generate_es256(
120+
kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing"
121+
) -> tuple[bytes, dict[str, Any]]:
122+
private = ec.generate_private_key(ec.SECP256R1())
123+
pem = private.private_bytes(
124+
encoding=serialization.Encoding.PEM,
125+
format=serialization.PrivateFormat.PKCS8,
126+
encryption_algorithm=_encryption_algorithm(passphrase),
127+
)
128+
jwk = _public_jwk_es256(private.public_key(), kid=kid, adcp_use=adcp_use)
118129
return pem, jwk
119130

120131

132+
def pem_to_adcp_jwk(
133+
pem: bytes,
134+
*,
135+
kid: str,
136+
purpose: Literal["request-signing", "webhook-signing"],
137+
password: bytes | None = None,
138+
) -> dict[str, Any]:
139+
"""Derive the public JWK for an existing AdCP signing PEM.
140+
141+
Companion to :func:`generate_signing_keypair` for the case where
142+
the key was minted elsewhere — typically in a managed key store
143+
(KMS / HSM / Vault) that exports the public half as a PEM. The
144+
output JWK is byte-shape-identical to what
145+
:func:`generate_signing_keypair` would have produced for the same
146+
key material, so it is safe to publish at the agent's
147+
``jwks_uri`` directly.
148+
149+
Why a helper at all? Three fields in the JWK are easy to mis-emit
150+
by hand and every wrong value yields a verifier rejection at the
151+
first signed request:
152+
153+
* ``alg`` — MUST be ``"EdDSA"`` for Ed25519, ``"ES256"`` for P-256
154+
(NOT the RFC 9421 ``alg`` casing used in ``Signature-Input``).
155+
* ``adcp_use`` — required by AdCP #2423; verifiers reject keys
156+
lacking it. MUST match the signing surface (``"request-signing"``
157+
vs. ``"webhook-signing"``).
158+
* ``key_ops`` — MUST be ``["verify"]`` (the public half cannot
159+
sign).
160+
161+
:param pem: PEM-encoded private key (PKCS#8). Pass the PEM only
162+
when the private half is at hand — for KMS deployments where
163+
the private material never leaves the managed store, pass an
164+
SPKI public-key PEM (``-----BEGIN PUBLIC KEY-----``) instead;
165+
the loader handles both forms.
166+
:param kid: JWK ``kid`` to embed. MUST match the value the signer
167+
will advertise via :meth:`SigningProvider.key_id`.
168+
:param purpose: Which AdCP signing profile this key is for. Sets
169+
``adcp_use``. Generate distinct keys per purpose — sharing
170+
material across request-signing and webhook-signing is a spec
171+
violation, not just a convention.
172+
:param password: Passphrase if ``pem`` is an encrypted private
173+
key.
174+
175+
:returns: Public JWK ready to publish in the agent's ``jwks_uri``.
176+
The private scalar (``d``) is NEVER included in the output.
177+
178+
:raises ValueError: ``purpose`` is not in
179+
``("request-signing", "webhook-signing")``; the PEM is not
180+
Ed25519 or ECDSA-P-256; the EC curve is not P-256.
181+
"""
182+
if purpose not in _ADCP_USE_VALUES:
183+
raise ValueError(f"purpose must be one of {_ADCP_USE_VALUES}, got {purpose!r}")
184+
if not kid:
185+
raise ValueError("kid must be a non-empty string")
186+
187+
# SPKI public-key PEMs use the exact header `-----BEGIN PUBLIC KEY-----`;
188+
# private-key PEMs use a different header. Match the full sentinel rather
189+
# than a substring so a future PEM type whose header contains the words
190+
# "PUBLIC" + "KEY" (e.g., a hypothetical encrypted-public-key form)
191+
# doesn't silently dispatch to the wrong loader.
192+
if b"-----BEGIN PUBLIC KEY-----" in pem[:128]:
193+
loaded = serialization.load_pem_public_key(pem)
194+
if not isinstance(loaded, (ed25519.Ed25519PublicKey, ec.EllipticCurvePublicKey)):
195+
raise ValueError(
196+
f"unsupported public key type {type(loaded).__name__} — "
197+
f"AdCP signing accepts Ed25519 or ECDSA-P-256 only"
198+
)
199+
if isinstance(loaded, ec.EllipticCurvePublicKey) and not isinstance(
200+
loaded.curve, ec.SECP256R1
201+
):
202+
raise ValueError(
203+
f"EC public key curve {loaded.curve.name} is not supported — only "
204+
f"P-256 (SECP256R1) is allowed"
205+
)
206+
public_key: ed25519.Ed25519PublicKey | ec.EllipticCurvePublicKey = loaded
207+
else:
208+
public_key = load_private_key_pem(pem, password=password).public_key()
209+
210+
if isinstance(public_key, ed25519.Ed25519PublicKey):
211+
return _public_jwk_ed25519(public_key, kid=kid, adcp_use=purpose)
212+
return _public_jwk_es256(public_key, kid=kid, adcp_use=purpose)
213+
214+
121215
def _default_kid(alg: str) -> str:
122216
"""Default ``kid`` — opaque, collision-resistant.
123217

0 commit comments

Comments
 (0)