Skip to content

Implement RFC 9421 request signing (AdCP 3.0 optional profile) #183

Description

@bokelley

Paired SDK work for the AdCP RFC 9421 request-signing profile in adcp#2323 (3.0 optional, 4.0 required — tracked in adcp#2307).

Scope

Implement client signing and server verification for outgoing/incoming AdCP requests per the Signed Requests (Transport Layer) profile. Conforms against the test vectors shipped in the spec repo at static/test-vectors/request-signing/.

Deliverables

Client-side (signer)

  • AdcpClient(signing_key=..., agent_url=...) — optional keyword-only params. signing_key is a cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey or EllipticCurvePrivateKey (P-256).
  • On every outgoing request to operations in the seller's advertised request_signing.required_for or supported_for, compute the canonical signature base per RFC 9421 §2.5 with the covered components mandated by the AdCP profile:
    • Always: @method, @target-uri, @authority
    • With body: content-type
    • With body AND seller advertises covers_content_digest: "required": content-digest (RFC 9530, sha-256)
    • If seller advertises covers_content_digest: "forbidden", signer MUST NOT cover content-digest.
  • Attach Signature, Signature-Input, and (when covered) Content-Digest headers.
  • Sign with Ed25519 (default via cryptography) or ES256 (P-256 ECDSA). For ES256, encode r||s per RFC 9421 §3.3.2 — NOT DER. cryptography's default is DER; convert.
  • Honor the profile's sig-params: created, expires (≤ created + 300), nonce (≥ 128 bits, base64url), keyid (matches a JWK kid in the signer's published JWKS), alg (ed25519 or ecdsa-p256-sha256), tag (exactly adcp/request-signing/v1).
  • CLI helper: python -m adcp.generate_signing_key [--alg ed25519|es256] → writes PEM + prints JWK with adcp_use: "request-signing" for publication at jwks_uri.

Server-side (verifier)

  • adcp.signing module: middleware for Flask, FastAPI, Starlette.
  • Signature: def verify_request_signature(request, *, options: VerifyOptions) -> VerifiedSigner.
  • Implement the 13-step verifier checklist + pre-check from the spec:
    0. Pre-check: reject unsigned request to operation in required_for with request_signature_required. Reject if Signature present without Signature-Input (or vice versa) — the downgrade loophole vector 019 catches this.
    1. Parse Signature-Input / Signature
    2. Params presence (all 6 required)
    3. Tag check (adcp/request-signing/v1)
    4. Alg allowlist
    5. Window (including expires > created, ±60s skew, ≤ 300s max)
    6. Covered components (considering covers_content_digest policy: required / forbidden / either)
    7. Resolve keyid → JWKS → agent entry (with SSRF validation per AdCP webhook SSRF rules and 30-second JWKS refetch cooldown BETWEEN refetches)
    8. Check use: "sig", key_ops contains verify, adcp_use == "request-signing" (note: strip adcp_use before feeding the JWK to strict consumers like PyJWT's strict modes — retain for policy check)
    9. Revocation check (BEFORE crypto verify to avoid cheap amplification)
    10. Cryptographic verification of the signature base
    11. Recompute and compare content-digest when covered
    12. Replay dedup on (keyid, nonce)
    13. Insert into replay cache only after all prior steps pass
  • Pluggable JWKS cache + SSRF-validated fetcher. Recommend httpx with a custom transport that validates the resolved IP against reserved ranges before connecting.
  • Pluggable replay store (in-memory cachetools.TTLCache default; Redis adapter via redis-py for multi-instance).
  • Return VerifiedSigner(key_id=..., agent_url=..., verified_at=...) for downstream authorization.
  • Emit typed errors matching the spec error taxonomy:
    • request_signature_required
    • request_signature_header_malformed
    • request_signature_params_incomplete
    • request_signature_tag_invalid
    • request_signature_alg_not_allowed
    • request_signature_window_invalid
    • request_signature_components_incomplete
    • request_signature_components_unexpected
    • request_signature_key_unknown
    • request_signature_key_purpose_invalid
    • request_signature_invalid
    • request_signature_digest_mismatch
    • request_signature_replayed
    • request_signature_key_revoked
    • request_signature_revocation_stale
    • request_signature_jwks_unavailable
    • request_signature_jwks_untrusted
  • Return 401 with WWW-Authenticate: Signature error="<code>" on rejection. Realm MUST be omitted per spec.
  • Respect the "one Signature-Input label per request" rule — verify sig1 and ignore additional labels.

Conformance tests

  • Consume the test vectors shipped in the spec repo under static/test-vectors/request-signing/:
    • 19 negative vectors — the verifier MUST return expected_outcome.error_code exactly. failed_step is informational.
    • 3 positive vectors — the verifier MUST accept. Ed25519 signatures are byte-exact reproducible (determinism); ES256 is verify-only (random-k is conformant for verify).
  • Run order per the README's "Recommended run order" section: positives first, then parse-level negatives, then semantic, then key-path, then crypto/stateful.
  • Add end-to-end test: spin up FastAPI with verify_request_signature, point an AdcpClient at it with signing_key, drive a create_media_buy. Exercise covers_content_digest: "either" and "required".

Docs

  • README section on "Signing in 3.0" — how to opt in as a buyer or a seller.
  • Python examples in the AdCP quickstart showing the 5-line integration.

Library recommendations

  • RFC 9421 canonicalization + verification: http-message-signatures (PyPI) is the most mature Python implementation. Validate its output against the expected_signature_base in each positive vector — the library supports pluggable covered-component selection and sig-params, so configuring it for the AdCP profile is straightforward.
  • JWK / JWKS: python-jose or cryptography directly. python-jose preserves unknown JWK members (adcp_use) on parse. cryptography's JWK handling is stricter; may need to strip adcp_use before conversion.
  • Content-Digest (RFC 9530): small custom impl — hashlib.sha256(body).digest() then base64-encode per RFC 8941 structured dictionary shape (sha-256=:<b64>:).
  • SSRF-validated fetch: httpx.HTTPTransport with custom connect hook, or a socket-level wrapper that validates the resolved IP against reserved ranges per the Webhook URL validation rules.
  • Ed25519/ES256: cryptography.hazmat.primitives.asymmetric.ed25519 and .ec. Note: cryptography signs ECDSA with DER by default; you need to convert to IEEE P1363 (r||s) for RFC 9421 — use cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature to get (r, s) then big-endian pack.

Ordering

  1. Implement the verifier first — it consumes the conformance vectors directly and validates the spec.
  2. Implement the signer.
  3. End-to-end tests + docs.

Why this belongs in adcp-client-python

The spec is language-agnostic; the SDK is what Python implementers actually integrate. Shipping signing support in the SDK is what turns "the profile is optional in 3.0" from a theoretical statement into something Python-based counterparties can pilot. Without this, 4.0 required-signing lands on unimplemented libraries.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions