Skip to content

feat(signing): add SigningProvider abstraction for KMS/HSM-backed signing - #481

Open
sujanchalla0510 wants to merge 1 commit into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-provider-kms
Open

feat(signing): add SigningProvider abstraction for KMS/HSM-backed signing#481
sujanchalla0510 wants to merge 1 commit into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-provider-kms

Conversation

@sujanchalla0510

Copy link
Copy Markdown

Summary

Closes #99. Adds a SigningProvider abstraction to adcp/v3/signing so an AdCP agent's RFC 9421 signing key no longer has to live in process memory — it can stay in AWS KMS / GCP KMS / Azure Key Vault / Vault Transit and only ever leave that boundary as an already-computed signature.

type SigningProvider interface {
    Sign(ctx context.Context, payload []byte) ([]byte, error)
    KeyID() string
    Algorithm() Algorithm
    PublicKey(ctx context.Context) (crypto.PublicKey, error)
}

This issue got three rounds of maintainer triage (see the issue thread) with specific, binding design constraints before it was marked implementation-ready. This PR follows that spec precisely — see "Design constraints from the triage thread" below for how each one was resolved, including the one left explicitly open for the PR author (PublicKey() on the interface).

Non-breaking

SignerOptions.KeyID + PrivateKey keep working exactly as before — NewSigner wraps them internally in a new InMemorySigningProvider. The new SignerOptions.Provider field is the alternative path. No existing caller (including the adcp/v3/webhook package, which also calls signing.NewSigner) needed a single-line change; the full existing test suite passes untouched.

SignRequest now runs the signing operation against r.Context() instead of a context-free helper — this applies to both the direct SignRequest call and the RoundTripper/signingTransport.RoundTrip path (the issue/triage only called out the RoundTrip path explicitly; this PR covers both since SignRequest is the one place that actually signs).

Design constraints from the triage thread

The issue's three-round triage (2026-04-25 → 2026-04-28) flagged the original proposal's Algorithm() string as a wire-breaking blocker and left one design question open for whoever picked this up. Resolved here:

  1. Algorithm() returns signing.Algorithm, not string. It's the RFC 9421 alg sig-param wire value ("ed25519" / "ecdsa-p256-sha256"), validated against Algorithm.Allowed() in NewSigner at construction time, not per-request.
  2. KMS/HSM errors never reach Error() unwrapped. SigningProvider.Sign/PublicKey implementations should return *signing.SigningErrorCode is a stable, loggable string; the raw backend SDK error (which can embed KMS key ARNs, GCP resource paths, Vault mount paths) is reachable only via errors.Unwrap/errors.As, never via Detail or Error(). adcp/v3/signing/awskms follows this for every error path, with a dedicated test (TestSignPropagatesKMSError) asserting the raw AWS error text never appears in Error().
  3. NewSigner integration is exactly the spec from the triage thread: SignerOptions.Provider SigningProvider, NewSigner accepts Provider != nil || PrivateKey != nil and short-circuits when Provider is set.
  4. RSA-PSS is out of scope — not in the AdCP profile's Algorithm.Allowed(), not implemented.
  5. r.Context() threading — done, and extended to cover SignRequest directly, not only the RoundTripper path the triage comment named.
  6. tmproto/signing.go untouched — zero-dep root-module path, correctly out of scope.
  7. KeyID() stability contract documented on the interface.
  8. The one open question — PublicKey(ctx) (crypto.PublicKey, error) on the interface — resolved: yes, added. The triage thread flagged this as a "resolve before freezing the interface, adding it later is breaking" decision left to the PR. Adding it now unlocks two safeguards the maintainer's own comment asked for if PublicKey() shipped:
    • NewPublicJWKFromProvider(ctx, provider, kid, adcpUse) — builds the jwks_uri publication JWK straight from a provider instead of hand-assembling kty/crv/alg (a real source of drift per the triage comment). adcpUse is a required parameter, not defaulted, because the spec requires distinct key material per signing purpose (spec(webhooks): unify webhook signing on RFC 9421 profile adcp#2423). Tested against the real verifier, including a negative case proving a JWK published under the wrong adcp_use is correctly rejected (CodeKeyPurposeInvalid) — the same failure class as spec conformance vector 008-wrong-adcp-use.
    • AssertProviderPublicKeyMatchesSPKI(ctx, provider, expectedSPKI) — a startup tripwire: pin the expected SPKI bytes alongside deployed code, call once after the listener binds, fail loudly if a managed key store silently rotated the key backing a kid instead of quietly signing with a key no verifier will accept.
    • Out of scope per the triage thread and left for separate issues: webhook SigningProvider support, and the Postgres ReplayStore gap (already being addressed by a parallel PR against signing: Postgres ReplayStore reference adapter (adcp/signing/pgreplay) #54/feat(signing): add Postgres-backed ReplayStore for distributed verifier deployments #105).

AWS KMS: dependency isolation

Per AGENTS.md's "Zero unnecessary dependencies" rule (root module has zero deps; sub-modules add deps only where needed) and the issue's own framing (a real cloud KMS SDK is unavoidably heavy), the AWS KMS provider ships as its own Go module: adcp/v3/signing/awskms, with its own go.mod pulling in aws-sdk-go-v2/service/kms. This mirrors registry/redisstore and registry/glidestore, which isolate go-redis/glide the same way from the dependency-free registry module. adcp/v3/signing itself gained zero new imports — confirmed by go build ./... / go vet ./... at the repo root and in adcp/v3 with no go.mod diff there.

The provider only supports ECDSA-P256 (AlgES256): AWS KMS has no Ed25519/EdDSA asymmetric signing key spec as of this writing, only RSA and NIST/SECG curves, so Algorithm() always returns AlgES256. It signs a locally-computed SHA-256 digest via KMS's Sign API (MessageType=DIGEST, SigningAlgorithm=ECDSA_SHA_256) and converts the ASN.1 DER-encoded response into the fixed-width 64-byte IEEE P1363 (r||s) encoding the AdCP profile's Signature header requires — the same conversion InMemorySigningProvider's ECDSA path does locally, verified byte-for-byte against a real key in tests.

PublicKey fetches via KMS's GetPublicKey and caches only a successful result (a mutex-guarded cache, not sync.Once) — per a production-incident lesson cited directly in the issue's triage thread: eagerly calling KMS before a listener binds can hang process startup indefinitely on the AWS SDK retryer's backoff with no visible error, and sync.Once would permanently poison the cache on one transient failure. New() itself never calls KMS.

What's tested vs. what requires live AWS (honest accounting)

Tested, real, no live AWS needed (10 tests, -race clean): Provider depends on SignAPI — a two-method interface (Sign, GetPublicKey) matching the exact subset of *kms.Client it calls, satisfiable by *kms.Client itself in production and by a fake in tests — the pattern the AWS SDK for Go v2 documents for unit-testing service clients. Against that fake:

  • exactly which fields Sign sends KMS (KeyId, MessageType=DIGEST, SigningAlgorithm=ECDSA_SHA_256, and a SHA-256 digest of the payload — not the raw payload);
  • the DER→P1363 signature conversion, verified against a real ecdsa.PrivateKey/ecdsa.Verify;
  • the GetPublicKeyx509.ParsePKIXPublicKey mapping;
  • cache-on-success-only behavior (TestPublicKeyFetchesAndCaches, TestPublicKeyRetriesAfterFailure);
  • the *signing.SigningError contract on every failure path (KMS error, mismatched response algorithm, malformed DER, GetPublicKey failure) — including the explicit "raw KMS error text never appears in Error()" assertion;
  • a full end-to-end round trip: awskms.Providersigning.NewSignerSignRequestNewPublicJWKFromProvidersigning.VerifyRequest, against a fake KMS backend that actually signs with a real ECDSA key (not canned bytes), so the whole chain is a genuine cryptographic round trip.

Not tested here, and said so plainly rather than overclaiming: an actual round trip against live AWS KMS — IAM permissions, key policy, throttling/retry behavior under real network conditions, and confirming KMS's actual GetPublicKey/Sign response encodings match what this package assumes (RFC 3279 §2.2.3 DER for the signature, X.509 SubjectPublicKeyInfo for the public key — both per AWS's own API documentation, but not independently verified against a live key in this PR). That requires an AWS account and is out of scope for an automated test suite; adcp/v3/signing/awskms's package doc describes the manual verification recipe (wire a real kms.NewFromConfig client into the same code path the tests exercise against the fake, sign, then verify with signing.VerifyRequestSignature — a successful verify is the end-to-end proof).

Known bootstrap gap (disclosed)

adcp/v3/signing/awskms/go.mod requires github.com/adcontextprotocol/adcp-go/adcp/v3 at the currently-published v3.0.0 tag — which predates SigningProvider (added in this same PR). This means go build in the awskms module against a fresh module-mode checkout won't resolve signing.SigningProvider until a new adcp/v3 tag is cut post-merge (at which point bumping the require line is a trivial follow-up, matching how registry/redisstore/registry/glidestore already pin their parent module).

I verified go build/go vet/go test -race all pass for awskms locally via cp go.work.example go.work (this repo's documented mechanism for cross-module development against unreleased local changes) — not glossing over this, just disclosing it plainly. adcp/v3/signing/awskms is added to go.work.example's use() block and passes scripts/check-goworkexample.sh. I did not wire a CI step for this new module: doing so today would require either exercising an untested workspace-mode CI mechanism (and go.work.example's own go 1.26.2 directive is already lower than some sibling modules' go 1.27.0 requirement — a pre-existing, unrelated issue I hit and worked around locally but didn't fix here) or accepting a step that fails until the tag bump lands. Once a new adcp/v3 tag ships, a completely ordinary cd adcp/v3/signing/awskms && go test ./... CI step — no workspace mode needed — becomes trivial to add, exactly matching every other module's CI step.

Test plan

  • go build ./... / go vet ./... — repo root: clean, no new deps (confirmed via git diff go.mod go.sum — no diff)
  • go build ./... / go vet ./... / go test -race -count=1 ./...adcp/v3 module: clean, full existing suite (including adcp/v3/webhook, which also calls signing.NewSigner) passes untouched
  • go build ./... / go vet ./... / go test -race -count=1 ./...adcp/v3/signing/awskms module (via go.work, see above): clean, 10/10 new tests pass
  • golangci-lint run ./...adcp/v3 (0 new issues; 3 pre-existing staticcheck findings in jwk.go, unrelated to this PR, from a newer Go 1.26 deprecation the repo hasn't addressed yet) and adcp/v3/signing/awskms (0 issues)
  • gofmt -l clean on every changed/new file
  • bash scripts/check-goworkexample.sh passes after adding the new module

Files

  • adcp/v3/signing/provider.goSigningProvider, InMemorySigningProvider, NewPublicJWKFromProvider, AssertProviderPublicKeyMatchesSPKI
  • adcp/v3/signing/errors.goSigningError, SigningErrorCode
  • adcp/v3/signing/sign.goSigner/NewSigner/SignRequest refactored onto SigningProvider
  • adcp/v3/signing/provider_test.go, adcp/v3/signing/awskms/provider_test.go — new tests
  • adcp/v3/signing/awskms/ — new module (provider, doc, go.mod/go.sum)
  • adcp/v3/signing/README.md, MIGRATION.md, doc.go, root README.md, go.work.example — docs + module registration

🤖 Generated with Claude Code

https://claude.ai/code/session_01NFth3sHBrJbHuFeA8Cegdc

…ning

Closes adcontextprotocol#99. adcp/v3/signing's Signer required the private key to live in
process memory. Add a context-aware SigningProvider interface so operators
can keep the key in a KMS/HSM/Vault instead: Sign(ctx, payload) ([]byte,
error), KeyID() string, Algorithm() Algorithm, PublicKey(ctx) (crypto.PublicKey,
error). InMemorySigningProvider wraps the existing behavior and stays the
default — SignerOptions.KeyID/PrivateKey keep working unchanged; the new
SignerOptions.Provider field is purely additive, no breaking change.
SignRequest threads r.Context() into the provider's Sign call (both the
direct-call and RoundTripper paths).

Incorporates the design constraints from this issue's own maintainer
triage thread (three review rounds, JS reference-implementation learnings
cited in the comments): Algorithm() returns the typed signing.Algorithm
(RFC 9421 wire value), not a bare string; provider-backed errors surface as
a typed *SigningError (Code is safe to log, the raw backend SDK error is
reachable only via errors.Unwrap/errors.As, never via Detail — KMS/GCP/
Vault error strings routinely embed resource ARNs/paths); the interface
ships PublicKey up front since adding it later would be breaking, which
unlocks NewPublicJWKFromProvider (jwks_uri publication straight from a
provider, adcp_use required per adcp#2423 key-separation) and
AssertProviderPublicKeyMatchesSPKI (startup tripwire against a managed
key store silently rotating the key backing a kid).

Ships a worked AWS KMS provider (adcp/v3/signing/awskms) against
aws-sdk-go-v2/service/kms's real Sign/GetPublicKey APIs for an
ECC_NIST_P256 key (AWS KMS has no Ed25519 asymmetric signing), converting
KMS's DER-encoded ECDSA signature to the AdCP profile's fixed-width IEEE
P1363 wire format. Isolated as its own go.mod — same pattern as
registry/redisstore and registry/glidestore — so importing adcp/v3/signing
never pulls in a cloud SDK (AGENTS.md: "Zero unnecessary dependencies").
PublicKey is lazily fetched and cached success-only (not sync.Once), per a
production incident cited in the issue's triage history (eager KMS calls
before a listener binds can hang startup on retryer backoff). Tested
end-to-end via a fake SignAPI (no live AWS needed) covering request
construction, DER->P1363 mapping, the SigningError contract, and a full
sign+publish+verify round trip through the real adcp/v3/signing verifier.

This PR's go.mod for the awskms module pins adcp/v3 at the currently
published v3.0.0 tag, which predates SigningProvider (added here, in the
same PR) — the same bootstrap gap any first submodule consuming brand-new
sibling code has. Verified build/vet/test locally via `cp go.work.example
go.work` (this repo's documented mechanism for exactly this); resolves
itself with the next adcp/v3 tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFth3sHBrJbHuFeA8Cegdc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add SigningProvider abstraction for external key management (KMS/HSM/Vault)

1 participant