Skip to content

feat(signing): ship signing/signingtest subpackage + ObserveOnly mode - #479

Open
sujanchalla0510 wants to merge 1 commit into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-signingtest-observeonly
Open

feat(signing): ship signing/signingtest subpackage + ObserveOnly mode#479
sujanchalla0510 wants to merge 1 commit into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-signingtest-observeonly

Conversation

@sujanchalla0510

Copy link
Copy Markdown

Closes #53.

Summary

  • adcp/v3/signing/signingtestNewTestAgent(t) builds a matched Ed25519 *signing.Signer + signing.MiddlewareOptions (a fresh StaticJWKSResolver seeded with the signer's public key, a fresh NewMemoryReplayStore(0), no revocation). SignAndSend(t, signer, handler, req) signs req (with CoverContentDigest: true) and delivers it to handler in-process via httptest.NewRecorder, returning the *http.Response. Together these collapse the ~30-line keypair/JWK/resolver/replay-store pattern middleware_test.go currently hand-rolls into two lines of setup + one line to send.
  • MiddlewareOptions.ObserveOnly bool — a real behavioral branch in Middleware(), not a passthrough field. When true, VerifyRequestSignature still runs; on failure the request is logged at slog.LevelInfo (vs the normal slog.LevelWarn) with an observe_only=true attribute and passed to next.ServeHTTP with no VerifiedSigner in its context — i.e., treated exactly as unsigned. On success, behavior is unchanged.

ObserveOnly ↔ spec mapping

This maps to the AdCP transport spec's warn_for rollout stop (supported_for → warn_for → required_for, see Transport capability advertisement). Per spec: "A missing signature or a well-formed signature that fails verification or body binding MUST NOT establish verified-signer identity ... A partial or malformed Signature / Signature-Input pair always hard-rejects."

ObserveOnly implements that split precisely, not just "log and let everything through":

  • A well-formed signature that fails verification (bad crypto, unknown/revoked key, expired window, replay, wrong content-digest, an unsigned request to a RequiredFor op, ...) is observed: INFO log, request passes through unauthenticated.
  • A partial or malformed Signature/Signature-Input header pair — one header present without the other, or either header present but unparseable (*Error{Code: CodeHeaderMalformed}) — still hard-rejects with 401 even under ObserveOnly, because the spec says it "cannot be safely interpreted as either signed or unsigned traffic."

adcp/v3/signing/MIGRATION.md's "Step B — warn_for" section previously described this exact mode as not yet landed, with an OnReject-based logs-and-passes-through shim as the interim workaround (and a "delete this before enabling RequiredFor" warning). This PR replaces that shim guidance with the real ObserveOnly API throughout the migration guide (bootstrap → step B → common pitfalls → pre-enforcement checklist).

Module note

This targets adcp/v3/signing, not adcp/signing. Per the repo's README.md "Modules & versioning" table and MIGRATING.md, adcp (the pre-v3 module) is frozen at v2.1.1 and receives security backports only — adcp/v3 is the actively developed module for AdCP 3.x. A new DX/feature package belongs there, so adcp/signing (the frozen copy) is untouched by this PR.

Testing

  • signingtest's own tests (adcp/v3/signing/signingtest/signingtest_test.go) prove NewTestAgent + SignAndSend produce a request a real signing.Middleware-wrapped handler accepts (VerifiedSignerFromContext populated, correct algorithm), that the wired verifier is real and not a stub (rejects unsigned traffic on a RequiredFor op, rejects a replayed signature), and that SignAndSend fails fast on a non-absolute request URL (verified via a re-exec'd subprocess, since a subtest's t.Fatalf can't be observed without failing the parent test/package).
  • middleware_test.go gets three new tests: ObserveOnly lets an unsigned request to a RequiredFor operation through (INFO log, no VerifiedSigner); ObserveOnly lets a well-formed-but-cryptographically-invalid signature through, with a matching ObserveOnly=false subtest proving the existing 401 behavior is unchanged; and ObserveOnly still hard-rejects a malformed Signature/Signature-Input pair (WARN log, 401, WWW-Authenticate: ... request_signature_header_malformed), proving the spec carve-out is real.
  • go build ./..., go vet ./..., gofmt -l, and golangci-lint run ./signing/... are clean for every file this PR touches. The three staticcheck findings golangci-lint reports in adcp/v3/signing/jwk.go (deprecated ecdsa.PublicKey.X/.Y/.PrivateKey.D field access, Go 1.26) are pre-existing on main and untouched by this PR.
  • go test ./... passes for both the adcp/v3 module (this change) and the root module (unaffected).

On item 5 of the issue ("refactor an existing boilerplate-heavy test as a demonstration")

I looked for one and came up empty, honestly reported: the request-signing profile's StaticJWKSResolver/NewMemoryReplayStore boilerplate pattern this issue targets appears nowhere else in the repo outside adcp/signing and adcp/v3/signing themselves. adcp/v3/webhook's test files (signing_test.go, publisher_test.go, e2e_test.go) use the webhook-signing profile via a different, already-existing dedicated helper (webhookKeypair) and a different verifier entry point (webhook.HTTPHandler, not signing.Middleware) — not a good refactor target for signingtest, which is scoped to the request-signing/signing.Middleware path the issue describes.

I also couldn't refactor adcp/v3/signing/middleware_test.go's own TestMiddlewareEndToEndSignAndVerify (the literal ~30-line pattern quoted in the issue) in place: that file is package signing (internal, white-box), and signingtest imports signing — importing signingtest from inside package signing would be a cyclic import. Instead, signingtest's own TestNewTestAgentSignAndSendRoundTrip reproduces the same scenario (sign, verify via middleware, assert on VerifiedSignerFromContext) using the new two-line + one-line helpers, as the concrete demonstration of the line-count reduction, and the package's README.md/doc.go cross-reference it for discoverability.

Test plan

  • cd adcp/v3 && go build ./... && go vet ./... && go test ./...
  • cd adcp/v3 && golangci-lint run ./signing/...
  • go build ./... && go vet ./... && go test ./... at repo root (unaffected — confirms no accidental frozen-module edits)
  • git status clean in the working tree aside from the files this PR touches

Every consumer writing a handler test that expects signed requests had to
reverse-engineer the ~30-line keypair/JWK/StaticJWKSResolver/replay-store
pattern in middleware_test.go. signingtest.NewTestAgent + SignAndSend
collapse that into a two-line setup and a one-line send.

Separately, MiddlewareOptions.ObserveOnly implements the spec's warn_for
shadow-mode rollout stop (between supported_for and required_for):
verification still runs, but a failing request reaches next.ServeHTTP
with no VerifiedSigner in its context instead of getting a 401, and the
failure is logged at INFO. A partial or malformed Signature/Signature-Input
pair still hard-rejects even under ObserveOnly, per the spec's explicit
carve-out that such a pair can't be safely read as signed or unsigned
traffic. MIGRATION.md's step-B guidance, which previously described this
as a not-yet-landed OnReject shim, is updated to use the real API.

Built against adcp/v3/signing — the active module per README's
"Modules & versioning" table; adcp/signing (v2) is frozen for security
backports only, so this feature does not touch it.

Closes adcontextprotocol#53
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.

signing: ship signing/signingtest subpackage + ObserveOnly mode

1 participant