diff --git a/adcp/v3/signing/MIGRATION.md b/adcp/v3/signing/MIGRATION.md index 4bc1f27b..1730c5b9 100644 --- a/adcp/v3/signing/MIGRATION.md +++ b/adcp/v3/signing/MIGRATION.md @@ -65,18 +65,22 @@ Success signal: signed requests arrive, `signing.VerifiedSignerFromContext(ctx)` Move the operation to `warn_for`. Verification still runs and failures are logged; traffic is unaffected. Watch your failure rate and walk down the long tail of "some counterparty is misbehaving" before flipping to reject. -This is the spec's shadow-mode stop. The SDK exposes it via `MiddlewareOptions.ObserveOnly` (tracked in [#53](https://github.com/adcontextprotocol/adcp-go/issues/53)); until that lands, approximate it with an `OnReject` that logs-and-passes-through: +This is the spec's shadow-mode stop. The SDK exposes it via `MiddlewareOptions.ObserveOnly`: verification runs on every signed request, but a failure — including an unsigned request to an operation you've also (mistakenly, since `warn_for` and `required_for` are disjoint) left in `RequiredFor` — is logged at INFO instead of rejected, and the request reaches your handler with no `VerifiedSigner` in its context: ```go -// WARNING: step-B shim only. Delete before enabling RequiredFor in step C — -// this turns every required-signed op into an unsigned op. -OnReject: func(w http.ResponseWriter, r *http.Request, e *signing.Error) { - logger.Warn("signature would reject", "code", e.Code, "detail", e.Detail) - // fall through to the next handler — request is NOT rejected. -}, +mw := signing.Middleware(signing.MiddlewareOptions{ + Resolver: jwksResolver, + Replay: signing.NewMemoryReplayStore(0), + Revocation: signing.NewStaticRevocationList(nil), + OperationResolver: signing.DefaultOperationResolver, + ObserveOnly: true, // step-B: verify, log, never reject + Logger: logger, +}) ``` -Success signal: grep your logs for the `request signature rejected` message (field `code` = `request_signature_*`) and watch the rate fall to zero — or to a known-and-tolerated set of counterparties — over a window long enough to cover your slowest integrator's deploy cadence. +One case still hard-rejects even under `ObserveOnly`: a partial or malformed `Signature`/`Signature-Input` header pair. Per spec, that pair "cannot be safely interpreted as either signed or unsigned traffic," so it 401s regardless of rollout stage — only a *well-formed* signature that fails verification (bad crypto, unknown key, expired window, replay, ...) is observed-and-passed-through. + +Success signal: grep your logs for `"signature verification failed (ObserveOnly...)"` (field `code` = `request_signature_*`, `observe_only=true`) and watch the rate fall to zero — or to a known-and-tolerated set of counterparties — over a window long enough to cover your slowest integrator's deploy cadence. ### Step C — `required_for` @@ -142,7 +146,7 @@ Ordering is different — the old kid must stop being trusted *before* anything - **Clock skew > 60s.** Verifiers reject with `request_signature_window_invalid` when `created` is > 60s in the future or `expires` is > 60s in the past. NTP-sync both sides; investigate container hosts that drift after suspend/resume. - **Custom `HTTPClient` losing SSRF protection.** If you supply `HTTPJWKSResolver.HTTPClient`, start from `signing.NewSafeHTTPClient()` — otherwise you lose the DNS-rebinding / private-IP / loopback guards, and an attacker who controls a `jwks_uri` can pivot against your internal network. - **Per-keyid replay cap.** The default in-memory replay store caps at 1,000,000 entries per keyid. Sustained > 3k QPS per signing key will trip `request_signature_rate_abuse`. Deploy a distributed replay store (Redis or equivalent, tracked in [#54](https://github.com/adcontextprotocol/adcp-go/issues/54)) before you get there. -- **`OnReject` that logs-and-passes-through past step B.** The step-B shim above is a footgun if it survives into production — it silently turns every required-signed op into an unsigned op. Before flipping `RequiredFor`, grep your middleware wiring for `OnReject` and confirm the shim is gone. Months later, if you inherit the repo, re-grep. +- **`ObserveOnly: true` that survives past step B.** It's a footgun if it survives into production unnoticed — it silently turns every required-signed op back into an unsigned op. Before flipping `RequiredFor` to enforce, grep your middleware wiring for `ObserveOnly` and confirm it's gone (or scoped to a different middleware instance covering only the operations still in `warn_for`). Months later, if you inherit the repo, re-grep. ## 5. Verification checklist before enforcing @@ -152,6 +156,6 @@ Ordering is different — the old kid must stop being trusted *before* anything - [ ] Revocation source is configured (not `nil` — the middleware logs a warning when `RequiredFor` is non-empty and `Revocation` is nil). - [ ] Replay store is either in-memory (single instance) or a shared backing store (distributed). - [ ] Logs from step B show zero unexpected failures over at least one full deploy cycle of your slowest counterparty. -- [ ] No `OnReject` pass-through shim remains in the middleware wiring (grep for `OnReject`). +- [ ] No `ObserveOnly: true` remains on the middleware instance you're about to enforce on (grep for `ObserveOnly`). - [ ] `CheckRedirect` is set on every signing client. - [ ] Clock sync monitoring is in place on signer and verifier hosts. diff --git a/adcp/v3/signing/README.md b/adcp/v3/signing/README.md index d1f669b3..1db66f0a 100644 --- a/adcp/v3/signing/README.md +++ b/adcp/v3/signing/README.md @@ -17,6 +17,21 @@ The package is validated against the spec's [conformance vectors](https://adcont Vectors live under `testdata/request-signing/`; tests are in `conformance_test.go`. +## Testing handlers that expect signed requests + +The [`signingtest`](./signingtest) subpackage collapses the keypair + JWK + +`StaticJWKSResolver` + `NewMemoryReplayStore` boilerplate a handler test +otherwise has to hand-roll: + +```go +signer, opts := signingtest.NewTestAgent(t) +opts.OperationResolver = signing.DefaultOperationResolver +opts.RequiredFor = []string{"create_media_buy"} +handler := signing.Middleware(opts)(yourHandler) + +resp := signingtest.SignAndSend(t, signer, handler, req) +``` + ## Signing (buyer side) ```go diff --git a/adcp/v3/signing/doc.go b/adcp/v3/signing/doc.go index 08bf8c1f..636d459d 100644 --- a/adcp/v3/signing/doc.go +++ b/adcp/v3/signing/doc.go @@ -47,4 +47,18 @@ // } // // v.KeyID, v.AgentURL, v.VerifiedAt, v.Algorithm available for audit // } +// +// # Shadow-mode rollout +// +// MiddlewareOptions.ObserveOnly maps to the spec's warn_for rollout stop +// between supported_for and required_for: verification still runs, but a +// failing request passes to next.ServeHTTP anyway (no VerifiedSigner +// attached), and the failure is logged at INFO instead of rejected. See +// MIGRATION.md's "Step B — warn_for" for the full staged-enforcement recipe. +// +// # Testing handlers that expect signed requests +// +// See the signingtest subpackage for NewTestAgent and SignAndSend, which +// collapse the keypair + JWK + resolver + replay-store setup a handler test +// otherwise has to hand-roll. package signing diff --git a/adcp/v3/signing/middleware.go b/adcp/v3/signing/middleware.go index 8d328a13..c4fd6fdd 100644 --- a/adcp/v3/signing/middleware.go +++ b/adcp/v3/signing/middleware.go @@ -113,6 +113,32 @@ type MiddlewareOptions struct { // Set this when your reverse proxy terminates TLS and the verifier sees // plain HTTP. SchemeOverride string + + // ObserveOnly puts this middleware instance in shadow mode. Verification + // still runs, but a request that fails it — including an unsigned + // request to an operation in RequiredFor — is NOT rejected: next is + // invoked with no VerifiedSigner in the context, exactly as if the + // request had arrived unsigned. The failure is logged at INFO level + // (instead of the usual WARN + 401) via Logger, so operators can watch + // the failure rate before promoting the operation to full enforcement. + // + // This maps to the spec's `warn_for` rollout stop — the shadow-mode + // bridge between `supported_for` (verified when present, never required) + // and `required_for` (verified and mandatory). Wire a dedicated + // Middleware instance with ObserveOnly=true for the operations you've + // moved to `warn_for`; RequiredFor should normally be empty on that + // instance, since `warn_for` and `required_for` are disjoint per spec — + // see https://adcontextprotocol.org/docs/building/implementation/security#transport-capability-advertisement. + // + // One case still hard-rejects even under ObserveOnly: a partial or + // malformed Signature/Signature-Input header pair (one header present + // without the other, or either header present but unparseable) — + // surfaced as *Error{Code: CodeHeaderMalformed}. The spec requires this + // because a broken pair "cannot be safely interpreted as either signed + // or unsigned traffic"; a well-formed signature that merely fails + // verification (bad crypto, unknown key, expired window, replay, ...) is + // the case ObserveOnly is for and passes through. + ObserveOnly bool } // Middleware returns an http.Handler middleware that verifies incoming AdCP @@ -187,13 +213,30 @@ func Middleware(opts MiddlewareOptions) func(http.Handler) http.Handler { if parsed, perr := parseSignatureInput(r.Header.Get(signatureInputHeader)); perr == nil { keyid = parsed.keyID } - logger.Warn("signature rejected", + // ObserveOnly never overrides a malformed header pair — that + // case cannot be safely treated as unsigned traffic (see the + // ObserveOnly doc comment). + observing := opts.ObserveOnly && e.Code != CodeHeaderMalformed + level := slog.LevelWarn + msg := "signature rejected" + if observing { + level = slog.LevelInfo + msg = "signature verification failed (ObserveOnly: request allowed through unverified)" + } + logger.Log(r.Context(), level, msg, "code", e.WireCode(profile), "detail", e.Detail, "op", opName, "keyid", keyid, "profile", profile.Tag, + "observe_only", observing, ) + if observing { + // No VerifiedSigner is attached — downstream sees this + // exactly as an unsigned request. + next.ServeHTTP(w, r) + return + } if opts.OnReject != nil { opts.OnReject(w, r, e) return diff --git a/adcp/v3/signing/middleware_test.go b/adcp/v3/signing/middleware_test.go index 72155556..a9e9e8fa 100644 --- a/adcp/v3/signing/middleware_test.go +++ b/adcp/v3/signing/middleware_test.go @@ -2,17 +2,66 @@ package signing import ( "bytes" + "context" "crypto/ed25519" + "encoding/base64" "io" + "log/slog" "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// recordingHandler is a minimal slog.Handler that captures every record it +// receives, so tests can assert on log level and attributes without parsing +// text/JSON output. Safe for concurrent use. +type recordingHandler struct { + mu sync.Mutex + records []slog.Record +} + +func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true } + +func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r) + return nil +} + +func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *recordingHandler) WithGroup(string) slog.Handler { return h } + +// last returns the most recently handled record, or the zero Record if none +// were captured. +func (h *recordingHandler) last() slog.Record { + h.mu.Lock() + defer h.mu.Unlock() + if len(h.records) == 0 { + return slog.Record{} + } + return h.records[len(h.records)-1] +} + +// attr returns the string value of attribute key on record r, or "" if +// absent. +func recordAttr(r slog.Record, key string) string { + var v string + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + v = a.Value.String() + return false + } + return true + }) + return v +} + func TestMiddlewareEndToEndSignAndVerify(t *testing.T) { // Build a fresh Ed25519 keypair for a round-trip. pub, priv, err := ed25519.GenerateKey(nil) @@ -136,3 +185,179 @@ func TestRoundTripperSignsAndBodyIsPreserved(t *testing.T) { assert.Equal(t, `{"a":1}`, string(receivedBody)) assert.NotEmpty(t, receivedSig) } + +// newObserveOnlyTestPair builds a signer + resolver pair for the ObserveOnly +// tests below, mirroring the boilerplate TestMiddlewareEndToEndSignAndVerify +// hand-rolls — exactly the pattern issue #53's signingtest package exists to +// collapse for consumers outside this file. +func newObserveOnlyTestPair(t *testing.T, kid string) (*Signer, *StaticJWKSResolver) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + jwk := &JWK{ + Kid: kid, + Kty: "OKP", + Crv: "Ed25519", + Alg: "EdDSA", + Use: "sig", + KeyOps: []string{"verify"}, + AdcpUse: "request-signing", + X: b64UrlEncodeRaw(pub), + } + resolver := NewStaticJWKSResolver() + resolver.Put(kid, jwk, "https://agent.example.com") + + signer, err := NewSigner(SignerOptions{KeyID: kid, PrivateKey: priv}) + require.NoError(t, err) + return signer, resolver +} + +// TestMiddlewareObserveOnlyAllowsUnsignedRequiredOp confirms the spec's +// warn_for behavior: an unsigned request to an operation that would normally +// be rejected under RequiredFor instead reaches the handler when +// ObserveOnly is set, with no VerifiedSigner attached, and the failure is +// logged at INFO (not the usual WARN) with observe_only=true. +func TestMiddlewareObserveOnlyAllowsUnsignedRequiredOp(t *testing.T) { + h := &recordingHandler{} + called := false + var gotSigner *VerifiedSigner + mw := Middleware(MiddlewareOptions{ + Resolver: NewStaticJWKSResolver(), + Replay: NewMemoryReplayStore(0), + OperationResolver: func(r *http.Request) string { return "create_media_buy" }, + RequiredFor: []string{"create_media_buy"}, + ObserveOnly: true, + Logger: slog.New(h), + }) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + gotSigner = VerifiedSignerFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "https://seller.example.com/adcp/create_media_buy", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called, "handler must run under ObserveOnly") + assert.Nil(t, gotSigner, "an unsigned request must not establish a VerifiedSigner, even under ObserveOnly") + + rec := h.last() + assert.Equal(t, slog.LevelInfo, rec.Level) + assert.Equal(t, string(CodeRequired), recordAttr(rec, "code")) + assert.Equal(t, "true", recordAttr(rec, "observe_only")) +} + +// TestMiddlewareObserveOnlyAllowsBadSignature confirms a well-formed but +// cryptographically invalid signature passes through under ObserveOnly +// (logged at INFO, no VerifiedSigner), and confirms the same request is +// rejected with 401 when ObserveOnly is false — the existing, unchanged +// behavior. +func TestMiddlewareObserveOnlyAllowsBadSignature(t *testing.T) { + newRequest := func(t *testing.T, signer *Signer) *http.Request { + t.Helper() + req, err := http.NewRequest("POST", "https://seller.example.com/adcp/create_media_buy", nil) + require.NoError(t, err) + require.NoError(t, signer.SignRequest(req, SignOptions{})) + // Corrupt the signature bytes while keeping the header well-formed + // (still `sig1=::`) — a well-formed pair that fails + // crypto verification, not a malformed pair. + sigHdr := req.Header.Get("Signature") + start := strings.Index(sigHdr, ":") + 1 + end := strings.LastIndex(sigHdr, ":") + require.Greater(t, end, start) + raw, err := base64.RawURLEncoding.DecodeString(sigHdr[start:end]) + require.NoError(t, err) + raw[0] ^= 0xFF + req.Header.Set("Signature", "sig1=:"+base64.RawURLEncoding.EncodeToString(raw)+":") + return req + } + + t.Run("ObserveOnly=true passes through", func(t *testing.T) { + signer, resolver := newObserveOnlyTestPair(t, "bad-sig-kid-observe") + h := &recordingHandler{} + called := false + var gotSigner *VerifiedSigner + mw := Middleware(MiddlewareOptions{ + Resolver: resolver, + Replay: NewMemoryReplayStore(0), + OperationResolver: func(r *http.Request) string { return "create_media_buy" }, + ObserveOnly: true, + Logger: slog.New(h), + }) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + gotSigner = VerifiedSignerFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, newRequest(t, signer)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called) + assert.Nil(t, gotSigner) + + rec := h.last() + assert.Equal(t, slog.LevelInfo, rec.Level) + assert.Equal(t, string(CodeInvalid), recordAttr(rec, "code")) + }) + + t.Run("ObserveOnly=false rejects (unchanged behavior)", func(t *testing.T) { + signer, resolver := newObserveOnlyTestPair(t, "bad-sig-kid-reject") + called := false + mw := Middleware(MiddlewareOptions{ + Resolver: resolver, + Replay: NewMemoryReplayStore(0), + OperationResolver: func(r *http.Request) string { return "create_media_buy" }, + }) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, newRequest(t, signer)) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.False(t, called) + assert.Equal(t, `Signature error="request_signature_invalid"`, w.Header().Get("WWW-Authenticate")) + }) +} + +// TestMiddlewareObserveOnlyStillHardRejectsMalformedPair confirms the one +// carve-out documented on MiddlewareOptions.ObserveOnly: a partial +// Signature/Signature-Input header pair still hard-rejects with 401 even +// under ObserveOnly, per the spec's rollout-pattern rule that such a pair +// "cannot be safely interpreted as either signed or unsigned traffic." +func TestMiddlewareObserveOnlyStillHardRejectsMalformedPair(t *testing.T) { + h := &recordingHandler{} + called := false + mw := Middleware(MiddlewareOptions{ + Resolver: NewStaticJWKSResolver(), + Replay: NewMemoryReplayStore(0), + OperationResolver: func(r *http.Request) string { return "create_media_buy" }, + ObserveOnly: true, + Logger: slog.New(h), + }) + handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + // Signature present, Signature-Input absent: a broken pair, not "unsigned". + req := httptest.NewRequest("POST", "https://seller.example.com/adcp/create_media_buy", nil) + req.Header.Set("Signature", "sig1=:AAAA:") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.False(t, called, "a malformed header pair must never reach the handler, even under ObserveOnly") + assert.Equal(t, `Signature error="request_signature_header_malformed"`, w.Header().Get("WWW-Authenticate")) + + rec := h.last() + assert.Equal(t, slog.LevelWarn, rec.Level, "malformed-pair rejection logs at the normal WARN level, not INFO") + assert.Equal(t, "false", recordAttr(rec, "observe_only")) +} diff --git a/adcp/v3/signing/signingtest/signingtest.go b/adcp/v3/signing/signingtest/signingtest.go new file mode 100644 index 00000000..4905249a --- /dev/null +++ b/adcp/v3/signing/signingtest/signingtest.go @@ -0,0 +1,141 @@ +// Package signingtest collapses the boilerplate of standing up a matched +// signer + verifier pair for tests of handlers that expect AdCP +// request-signing (RFC 9421) traffic. +// +// Writing that pair by hand means generating a keypair, building a JWK with +// the five fields the profile requires, wiring a signing.StaticJWKSResolver, +// and constructing a signing.NewMemoryReplayStore — roughly 30 lines +// reverse-engineered from adcp/v3/signing's own middleware_test.go every time +// a consumer needs it. NewTestAgent does that wiring once; SignAndSend is the +// one-liner for "send a signed request to this handler." +// +// func TestCreateMediaBuyRequiresSignature(t *testing.T) { +// signer, opts := signingtest.NewTestAgent(t) +// opts.OperationResolver = signing.DefaultOperationResolver +// opts.RequiredFor = []string{"create_media_buy"} +// handler := signing.Middleware(opts)(yourHandler) +// +// req := httptest.NewRequest(http.MethodPost, +// "https://seller.example.com/adcp/create_media_buy", +// strings.NewReader(`{"plan_id":"p1"}`)) +// req.Header.Set("Content-Type", "application/json") +// +// resp := signingtest.SignAndSend(t, signer, handler, req) +// if resp.StatusCode != http.StatusOK { +// t.Fatalf("got %d", resp.StatusCode) +// } +// } +// +// This package imports "testing" and is intended for use from _test.go +// files only. +package signingtest + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/adcontextprotocol/adcp-go/adcp/v3/signing" +) + +// testAgentURL is the agent URL published against the generated key in the +// resolver NewTestAgent builds. Callers that assert on +// signing.VerifiedSigner.AgentURL can compare against this constant. +const testAgentURL = "https://signingtest.invalid/agent" + +// NewTestAgent generates a fresh Ed25519 keypair and returns a Signer built +// from it (for constructing outbound signed requests) alongside +// MiddlewareOptions pre-wired to verify signatures from that same key: +// +// - Resolver: a signing.StaticJWKSResolver containing the signer's public +// JWK under a kid derived from the test name. +// - Replay: a fresh signing.NewMemoryReplayStore(0). +// - Revocation: nil (no revocation checking — the middleware logs a +// warning about this only when RequiredFor is non-empty, matching +// signing.Middleware's own dev/test posture). +// +// The returned MiddlewareOptions has no OperationResolver or RequiredFor set; +// callers wire those (e.g. signing.DefaultOperationResolver) when the test +// needs to exercise the RequiredFor / ObserveOnly gating rather than just a +// bare verified round trip. +// +// Each call to NewTestAgent produces an independent keypair and resolver, so +// concurrent subtests (t.Run with t.Parallel) do not share replay or key +// state. +func NewTestAgent(t *testing.T) (*signing.Signer, signing.MiddlewareOptions) { + t.Helper() + + kid := "signingtest-" + sanitizeKid(t.Name()) + res, err := signing.GenerateSigningKey(signing.AlgEd25519, kid) + if err != nil { + t.Fatalf("signingtest: generate signing key: %v", err) + } + priv, _, err := signing.LoadPrivateKey(res.PrivateKeyPEM) + if err != nil { + t.Fatalf("signingtest: load generated private key: %v", err) + } + signer, err := signing.NewSigner(signing.SignerOptions{ + KeyID: kid, + PrivateKey: priv, + }) + if err != nil { + t.Fatalf("signingtest: construct signer: %v", err) + } + + resolver := signing.NewStaticJWKSResolver() + resolver.Put(kid, &res.PublicJWK, testAgentURL) + + opts := signing.MiddlewareOptions{ + Resolver: resolver, + Replay: signing.NewMemoryReplayStore(0), + Revocation: nil, + } + return signer, opts +} + +// SignAndSend signs req with signer (covering content-digest, per the AdCP +// recommendation for spend-committing operations — see the signing package +// README's "Caveats" section) and delivers it directly to handler via +// httptest.NewRecorder, returning the resulting *http.Response. +// +// req.URL must be absolute (e.g. constructed with +// httptest.NewRequest(method, "https://seller.example.com/adcp/op", body) or +// http.NewRequest with a full URL) — SignRequest needs an absolute +// @target-uri to sign, and serving the request in-process (rather than over +// a real listener) means the handler sees exactly the URL that was signed, +// with no scheme/host rewriting to account for. +func SignAndSend(t *testing.T, signer *signing.Signer, handler http.Handler, req *http.Request) *http.Response { + t.Helper() + + if req.URL == nil || !req.URL.IsAbs() { + t.Fatalf("signingtest: SignAndSend requires req.URL to be absolute (e.g. https://seller.example.com/adcp/create_media_buy), got %q", req.URL) + } + + if err := signer.SignRequest(req, signing.SignOptions{CoverContentDigest: true}); err != nil { + t.Fatalf("signingtest: sign request: %v", err) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result() +} + +// sanitizeKid maps a *testing.T name (which may contain '/' from subtests +// and spaces from t.Run names built with fmt.Sprintf) to characters safe for +// a JWK kid and an RFC 9421 quoted-string sig-param. +func sanitizeKid(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + if b.Len() == 0 { + return "t" + } + return b.String() +} diff --git a/adcp/v3/signing/signingtest/signingtest_test.go b/adcp/v3/signing/signingtest/signingtest_test.go new file mode 100644 index 00000000..b8fd93c8 --- /dev/null +++ b/adcp/v3/signing/signingtest/signingtest_test.go @@ -0,0 +1,145 @@ +package signingtest_test + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + + "github.com/adcontextprotocol/adcp-go/adcp/v3/signing" + "github.com/adcontextprotocol/adcp-go/adcp/v3/signing/signingtest" +) + +// TestNewTestAgentSignAndSendRoundTrip proves NewTestAgent + SignAndSend +// produce a request a real signed-request-expecting handler accepts, with +// the verified identity available to the handler via +// signing.VerifiedSignerFromContext. +func TestNewTestAgentSignAndSendRoundTrip(t *testing.T) { + signer, opts := signingtest.NewTestAgent(t) + opts.OperationResolver = signing.DefaultOperationResolver + opts.RequiredFor = []string{"create_media_buy"} + + var gotSigner *signing.VerifiedSigner + handler := signing.Middleware(opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotSigner = signing.VerifiedSignerFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "https://seller.example.com/adcp/create_media_buy", + strings.NewReader(`{"plan_id":"p1"}`)) + req.Header.Set("Content-Type", "application/json") + + resp := signingtest.SignAndSend(t, signer, handler, req) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if gotSigner == nil { + t.Fatal("handler saw no VerifiedSigner — signature was not accepted") + } + if gotSigner.Algorithm != signing.AlgEd25519 { + t.Errorf("Algorithm = %q, want %q", gotSigner.Algorithm, signing.AlgEd25519) + } +} + +// TestNewTestAgentRejectsUnsignedWhenRequired confirms the MiddlewareOptions +// NewTestAgent returns wire a real, functioning verifier — not a stub that +// accepts everything — by checking an unsigned request to a RequiredFor +// operation is rejected. +func TestNewTestAgentRejectsUnsignedWhenRequired(t *testing.T) { + _, opts := signingtest.NewTestAgent(t) + opts.OperationResolver = signing.DefaultOperationResolver + opts.RequiredFor = []string{"create_media_buy"} + + called := false + handler := signing.Middleware(opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "https://seller.example.com/adcp/create_media_buy", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if called { + t.Fatal("handler ran on an unsigned request to a required operation") + } +} + +// TestNewTestAgentReplayIsEnforced confirms the replay store NewTestAgent +// wires is real: replaying the exact same signed request a second time is +// rejected as request_signature_replayed, proving the (keyid, nonce) dedup +// state SignAndSend exercises is not a no-op. +func TestNewTestAgentReplayIsEnforced(t *testing.T) { + signer, opts := signingtest.NewTestAgent(t) + opts.OperationResolver = signing.DefaultOperationResolver + + handler := signing.Middleware(opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req, err := http.NewRequest(http.MethodPost, "https://seller.example.com/adcp/create_media_buy", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + if err := signer.SignRequest(req, signing.SignOptions{}); err != nil { + t.Fatalf("sign request: %v", err) + } + + // First delivery: accepted. + rec1 := httptest.NewRecorder() + handler.ServeHTTP(rec1, req.Clone(req.Context())) + if rec1.Code != http.StatusOK { + t.Fatalf("first delivery status = %d, want 200", rec1.Code) + } + + // Replaying the identical Signature/Signature-Input headers must be + // rejected. + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req.Clone(req.Context())) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("replayed delivery status = %d, want 401", rec2.Code) + } + if got := rec2.Header().Get("WWW-Authenticate"); !strings.Contains(got, "request_signature_replayed") { + t.Errorf("WWW-Authenticate = %q, want it to contain request_signature_replayed", got) + } +} + +// TestSignAndSendRequiresAbsoluteURL confirms SignAndSend fails fast (via +// t.Fatalf) rather than producing a confusing downstream signing error when +// handed a relative request URL. +// +// A t.Fatalf inside a subtest (t.Run) always propagates as a failure of the +// parent test and the whole package run — there is no in-process way to +// observe "the helper correctly called t.Fatalf" without failing this test +// binary's own run. So, like the standard library's own +// os/exec-style TestHelperProcess pattern, the failing call is made in a +// re-exec'd subprocess and its output is asserted on instead. +func TestSignAndSendRequiresAbsoluteURL(t *testing.T) { + if os.Getenv("SIGNINGTEST_RUN_ABSOLUTE_URL_SUBPROCESS") == "1" { + signer, _ := signingtest.NewTestAgent(t) + req := httptest.NewRequest(http.MethodGet, "/adcp/get_products", nil) + // httptest.NewRequest defaults req.URL to an absolute-path (no + // scheme/host) URL for a target without one — exactly the case + // SignAndSend must catch. + resp := signingtest.SignAndSend(t, signer, http.NotFoundHandler(), req) + defer resp.Body.Close() //nolint:errcheck + return + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestSignAndSendRequiresAbsoluteURL$", "-test.v") //nolint:gosec // re-exec of this same test binary, the standard os/exec TestHelperProcess pattern + cmd.Env = append(os.Environ(), "SIGNINGTEST_RUN_ABSOLUTE_URL_SUBPROCESS=1") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("subprocess unexpectedly succeeded (SignAndSend should have failed the test on a relative URL); output:\n%s", out) + } + if !strings.Contains(string(out), "requires req.URL to be absolute") { + t.Fatalf("subprocess failed, but not with the expected message; output:\n%s", out) + } +}