Skip to content

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

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)

  • adcp.Client accepts optional SigningKey (ed25519.PrivateKey or ECDSA *PrivateKey) + AgentURL string (identifies the signer's agents[] entry).
  • 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 crypto/ed25519) or ES256 (via crypto/ecdsa with IEEE P1363 encoding — NOT DER).
  • 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: adcp-go 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 package: middleware for net/http, github.com/gorilla/mux, github.com/go-chi/chi.
  • Signature: func VerifyRequestSignature(r *http.Request, opts VerifyOptions) (*VerifiedSigner, error).
  • 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" (strip adcp_use before feeding JWK to crypto/ecdsa or similar strict consumers — 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 (http.Client with DialContext hooks).
  • Pluggable replay store (in-memory sync.Map + expiry goroutine default; Redis adapter for multi-instance deployments).
  • Return *VerifiedSigner{KeyID, AgentURL, VerifiedAt} 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 an HTTP server with VerifyRequestSignature, point a client at it with SigningKey, 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.
  • Go examples in the AdCP quickstart showing the 5-line integration.

Library recommendations

  • RFC 9421 canonicalization + verification: github.com/remitly-oss/httpsig-go is currently the most active Go implementation. Validate its output against the expected_signature_base in each positive vector before relying on it — Go RFC 9421 libraries are patchier than TS/Python and may need config to match the AdCP profile exactly (specific covered components, IEEE P1363 ES256 encoding).
  • JWK / JWKS: github.com/go-jose/go-jose/v4 — works with stdlib crypto/ed25519 and crypto/ecdsa keys; preserves unknown JWK members like adcp_use.
  • Content-Digest (RFC 9530): may need a small custom impl; the header format is simple structured fields (sha-256=:base64:).
  • SSRF-validated fetch: use http.Client with Transport.DialContext to pin the connection to a pre-validated IP per the Webhook URL validation rules.

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-go

The spec is language-agnostic; the SDK is what Go 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 Go-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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions