chore: release package#1014
Merged
Merged
Conversation
baa19a2 to
66b9890
Compare
fb793c4 to
a4d4ae9
Compare
5 tasks
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
…ts (#1018) * feat(signing): PostgresReplayStore for distributed verifier deployments The default InMemoryReplayStore is per-process — multi-instance verifier fleets leak replay protection across the boundary because each box has its own cache. RFC 9421's 5-minute window bounds the gap but it's plenty of time for an in-flight replay. PostgresReplayStore closes the hole using a (keyid, scope, nonce) primary key the verifier checks on every signed request. New exports from @adcp/client/signing/server: - PostgresReplayStore (against the structural PgQueryable interface; same pattern as PostgresTaskStore / PostgresStateStore) - getReplayStoreMigration(tableName?) — idempotent DDL with indexes on expires_at and (keyid, scope, expires_at) - sweepExpiredReplays(pool, options?) — Postgres has no native row-level TTL; callers schedule this helper themselves (cron, app timer, pg_cron) The insert path is one CTE statement that handles replay/cap/insert atomically. ON CONFLICT DO UPDATE WHERE existing-is-expired recycles expired rows in place — a same-nonce insert after the previous registration's TTL elapsed (but before the sweeper ran) correctly returns 'ok' rather than falsely 'replayed'. Concurrent same-nonce inserts (10 parallel) consistently produce exactly one 'ok' and the rest 'replayed', matching InMemoryReplayStore semantics. 13 integration tests against real Postgres (skipped when DATABASE_URL is unset, same convention as postgres-task-store.test.js): - Migration + custom table names + SQL-injection-shaped name rejection - Replay precedence + (keyid, scope) partitioning - TTL boundary (has() respects expiry; insert recycles expired nonces) - Cap precedence: replay wins over rate_abuse wins over ok - Sweeper: full + batched - Concurrency: 10 parallel inserts produce exactly one ok - E2E: signed request → verifier with PostgresReplayStore → second attempt rejected as replay Wire format unchanged. SDK minor: accumulates into Release PR #1014. Closes #1015. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(signing): tighten PostgresReplayStore per expert review Three reviewers (security, code, protocol) cleared PR #1018 with no blockers but flagged consensus follow-ups: - Add `now`/`ttlSeconds` validation (security M2). PG `to_timestamp(NaN)` raises a parse error and `to_timestamp(±Infinity)` silently produces an `infinity` timestamp; a buggy `options.now()` injection point could DoS the verifier with PG errors. New `assertFiniteSeconds` helper rejects non-finite or out-of-range inputs at the three method boundaries with a clear TypeError. - Add `REPLAY_CACHE_MIGRATION` constant alongside `getReplayStoreMigration` to match the `MCP_TASKS_MIGRATION` / `ADCP_STATE_MIGRATION` convention in the sibling Postgres stores. - JSDoc note that `cap` is best-effort under concurrency — two concurrent inserts at `cap-1` can both observe the same MVCC snapshot and briefly overshoot. Matches `InMemoryReplayStore` semantics; the cap is a soft DoS guard, not a hard invariant. Five new tests: - Cap clears once entries pass expiry without the sweeper running (the prune-then-check semantic from `InMemoryReplayStore`, locked in so a future schema change can't quietly regress it — protocol blocker). - Concurrent recycle of an expired same-nonce: 10 parallel inserts past expiry produce exactly 1 `ok` and 9 `replayed` (the security review asked for this; the SQL was already correct, just untested). - `sweepExpiredReplays` with `batchSize` larger than the active expired count returns the actual count without error. - `now` / `ttlSeconds` validation: NaN, ±Infinity, negative all rejected. - End-to-end rate-abuse via the verifier pipeline: cap of 2, third unique signed request rejected with `request_signature_rate_abuse` (covers the conformance-vector-`negative/020` rejection path). `setCapHitForTesting` hook intentionally omitted — the protocol reviewer suggested option (a) "document the limitation" over (b) "add the hook," and white-box conformance grading against Postgres can use `cap: small` + real inserts to reach the cap-hit state. Test totals: 18/18 PostgresReplayStore integration tests + 175 existing signing tests — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a4d4ae9 to
8924329
Compare
7 tasks
bokelley
added a commit
that referenced
this pull request
Apr 26, 2026
#1019) * feat(testing): adcp grade signer — validate a signer end-to-end before going live Pairs with #1017's SigningProvider abstraction. After standing up a KMS adapter, operators need a way to validate "is this thing producing valid AdCP signatures?" before pushing live signed traffic — otherwise the only feedback channel is the seller's `request_signature_invalid` rejection, which is generic and arrives only after the misconfiguration is in production. The grader produces a sample signed AdCP request through the operator's signer, then verifies it against the operator's published JWKS via the SDK's RFC 9421 verifier. Pass means a counterparty will accept your signatures; fail surfaces a specific verifier error_code + step, so DER-vs-P1363 / kid-mismatch / wrong-key / algorithm-mismatch each show up as distinct diagnostics. Two signer-source modes: - `--key-file <path>` for local dev (JWK with `d`) - `--signer-url <url>` for KMS-backed signers — minimal HTTP oracle protocol (POST {payload_b64, kid, alg} → {signature_b64}) so any KMS adapter can put a small handler in front of provider.sign() without exposing the underlying KMS to the grader Programmatic API: `gradeSigner(options)` exported from `@adcp/client/testing/storyboard/signer-grader`. Returns `SignerGradeReport` with passed/step/diagnostic + the sample request the signer produced headers for. CLI: adcp grade signer <agent-url> --signer-url ... | --key-file ... --kid <kid> --alg <ed25519|ecdsa-p256-sha256> --jwks-url <url> [--operation <name>] [--signer-auth <header>] [--timeout <ms>] [--allow-http] [--json] 9 unit/integration tests cover both signer-source modes, the verifier failure paths a misconfigured adapter would hit (algorithm mismatch, wrong-key signing, signer-url 401, JWKS unreachable), and the input validation (both modes / missing modes / missing key file). Local CLI smoke test: $ adcp grade signer http://127.0.0.1:9999/agent \\ --key-file ./key.jwk --kid test-ed25519-2026 \\ --alg ed25519 --jwks-url http://127.0.0.1:N/.well-known/jwks.json \\ --allow-http Result: PASS diagnostic: Signature verified end-to-end against JWKS. Closes #610. Accumulates into Release PR #1014 (still 5.20.0 minor). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(testing): tighten signer grader per expert review (SSRF, digest, errors) Three reviewers (security, code, protocol). Security flagged two HIGH blockers — both collapse into routing the signing-oracle POST through ssrfSafeFetch. Protocol flagged two BLOCKERS around content-digest policy (the most common KMS-adapter misconfiguration was being masked). Plus the should-fix items from code review. Security blockers (HIGH): - httpOracleProvider used plain fetch(), bypassing the SDK's SSRF guard. Concrete attack: an internal attacker sets --signer-url to IMDS or redirects the POST to capture the --signer-auth Bearer token. Now routes through ssrfSafeFetch — gets DNS pinning, IMDS-block, manual-redirect (no Location-following), and a 64 KiB body cap. - --signer-auth header could leak via 302 redirect. Same fix — ssrfSafeFetch refuses to follow redirects. - No response-body size cap. Same fix — ssrfSafeFetch caps at 64 KiB. Protocol blockers: - Hardcoded covers_content_digest: 'either' masked the digest-policy misconfiguration KMS adapters most commonly hit (forgetting to emit Content-Digest against a 'required' verifier). Now defaults to 'required' (recommended posture for spend-committing operations) and exposes --covers-content-digest [required|forbidden|either] so operators match what their verifier actually advertises. - Sample body now reliably triggers step 11 (digest recompute) when policy is 'required' — the signer emits Content-Digest, the verifier exercises the digest path. Test asserts both branches: the header is present under 'required', omitted under 'forbidden'. Code-review fixes: - Dead branch for verifier_returned_unsigned (unreachable — signRequestAsync always emits headers) replaced with a hard throw so an SDK bug surfaces loudly rather than masquerading as a custom code. - error_code typed as a discriminated union (SignerGradeErrorCode = RequestSignatureErrorCode | SignerGraderErrorCode) so consumers writing exhaustive switch over the report see grader-side and verifier-side failures as distinct categories. - Case-insensitive header lookup in the CLI report printer (a future signer that emits lowercase header names won't silently elide them). - Lib + CLI error messages now agree on flag names (--key-file / --signer-url) so --json reports use names operators recognize. Protocol note: - Layered DER-vs-P1363 hint on top of step-10 errors when algorithm is ECDSA-P256. The most likely cause of a generic request_signature_invalid in that case is a KMS adapter that didn't run output through derEcdsaToP1363; surfacing this inline saves operators a debugging cycle. Security MED: - Stderr breadcrumb when --key-file is loaded so CI logs make the dev-tool nature visible — operators reviewing the log can confirm the file isn't checked in. Test totals: 11/11 (was 9). New tests: required-policy emits Content-Digest; forbidden-policy omits it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8924329 to
a4dfddc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@adcp/client@5.20.0
Minor Changes
b43b39d: feat(signing): PostgresReplayStore for distributed verifier deployments
Adds a Postgres-backed
ReplayStoreso multi-instance verifier deployments share replay-protection state. The defaultInMemoryReplayStoreis per-process; on a fleet, an attacker who captures a signed request can replay it against a sibling whose cache hasn't seen the nonce — RFC 9421's 5-minute expiry bounds the window but that's plenty of time for an in-flight replay.PostgresReplayStorecloses that hole using a(keyid, scope, nonce)primary key the verifier checks on every signed request.New exports from
@adcp/client/signing/server:PostgresReplayStore—ReplayStoreimplementation against the structuralPgQueryableinterface (same pattern asPostgresTaskStoreandPostgresStateStore; the SDK stays free of a hardpgdependency).getReplayStoreMigration(tableName?)— idempotent DDL for the cache table plus indexes onexpires_atand(keyid, scope, expires_at).sweepExpiredReplays(pool, options?)— exported helper for callers to schedule (cron, app timer,pg_cron, etc.); Postgres has no native row-level TTL, so expired rows have to be deleted explicitly.The insert path is a single CTE statement that handles replay/cap/insert decision atomically.
ON CONFLICT DO UPDATE WHERE existing-is-expiredrecycles expired rows in place — a same-nonce insert after the previous registration's TTL elapsed (but before the sweeper ran) correctly returns'ok'rather than falsely reporting'replayed'. Concurrent same-nonce inserts (10 parallel) consistently produce exactly one'ok'and the rest'replayed', matchingInMemoryReplayStoresemantics.Wire format unchanged. No AdCP version bump.
See
docs/guides/SIGNING-GUIDE.md§ Verify Inbound Signatures for the multi-instance failure mode and the wire-up.Closes Distributed ReplayStore adapter (Redis) for multi-instance verifier deployments #1015.
78fdb54: feat(testing):
adcp grade signer— validate a signer end-to-end before going liveAdds a CLI grader and matching library function that exercises a signer (typically KMS-backed) end-to-end: produces a sample signed AdCP request through the operator's signer, then verifies the result against the operator's published JWKS via the SDK's RFC 9421 verifier. Pass means a counterparty verifier will accept your signatures; fail produces a specific
error_code+ step matching the verifier-checklist semantics, so DER-vs-P1363 / kid-mismatch / wrong-key / algorithm-mismatch each surface as a distinct diagnostic instead of the genericrequest_signature_invalidyou'd see in the seller's monitoring after pushing live traffic.Two signer-source modes:
--key-file <path>— local JWK file. Easy path for local dev / non-KMS testing.--signer-url <url>— HTTP signing oracle for KMS-backed signers. Wire contract is intentionally minimal —POST {payload_b64, kid, alg}returns{signature_b64}(raw wire-format bytes, not DER) — so any KMS adapter can put a small handler in front ofprovider.sign()for grading without exposing the underlying KMS to the grader.Programmatic API:
gradeSigner(options)exported from@adcp/client/testing/storyboard/signer-grader. Returns aSignerGradeReportwithpassed,step.{status,error_code,diagnostic}, the JWKS URI it resolved against, and the sample request the signer produced headers for (useful for operator-side diagnostics).Pairs with the
SigningProviderabstraction (also in 5.20.0) — that release added the surface for KMS-backed signing; this one closes the loop by giving operators a way to validate their adapter before going live.Closes signing: signer-side conformance grader (complement to the verifier grader in #585) #610.
c4afc75: feat(signing): add SigningProvider abstraction for KMS-backed RFC 9421 signing
Adds a pluggable
SigningProviderinterface so private keys can live in amanaged key store (GCP KMS, AWS KMS, Azure Key Vault, HashiCorp Vault Transit)
instead of process memory. The async
sign(payload)boundary matches RFC9421 §3.1 — the SDK produces the canonical signature base, the provider
returns wire-format signature bytes.
New surface:
SigningProviderinterface andAdcpSignAlgtype (exported from@adcp/client/signing).signRequestAsync/signWebhookAsync— async variants that accept aprovider; sync
signRequest/signWebhookare unchanged.createSigningFetchAsync(upstream, provider, options)— async-signingfetch wrapper, paired with the existing sync
createSigningFetch. Twosymbols rather than one overload so the latency-cost distinction is
visible at integration time.
derEcdsaToP1363(der, componentLen)— DER → IEEE P1363 ECDSA signatureconverter for KMS adapters whose
signAPI returns DER (GCP, AWS, Azure).SigningProviderAlgorithmMismatchError— typed error adapters throw whenthe declared algorithm doesn't match the underlying key, so misconfigurations
fail fast at adapter construction rather than producing signatures verifiers
reject downstream.
@adcp/client/signing/testingsub-path exportingInMemorySigningProviderand
signerKeyToProvider. Constructor refuses to instantiate whenNODE_ENV=productionunlessADCP_ALLOW_IN_MEMORY_SIGNER=1is set.AgentRequestSigningConfigis now a discriminated union onkind:kind: 'inline'(default —kindis optional on this shape so existingliterals work unchanged) holds a private JWK in process.
kind: 'provider'delegatessign()to aSigningProvider.buildAgentSigningContextdefensively hashes the provider-suppliedfingerprinttogether withalgorithmandkidbefore composingtransport- and capability-cache keys, preserving the multi-tenant isolation
property the in-memory path has always provided. The signing identity is
snapshotted at context-build time so a provider object whose fields drift
between build and outbound request cannot desynchronize the on-wire
keyidfrom the cache key the connection was bound to.
Behavior change for non-UTF-8 byte bodies:
createSigningFetchandcreateSigningFetchAsyncnow throwTypeErroronUint8Array/ArrayBufferrequest bodies that aren't valid UTF-8. Previously, invalidbytes were silently replaced with U+FFFD by
Buffer.toString('utf8')—verification still passed because the wire and the digest agreed on the
lossy string, but the seller received mangled content. Callers hitting
this should pass a string body, ensure their bytes are UTF-8, or sign
manually with
signRequest/signRequestAsyncagainst the exact wirebytes they intend to send. Error message names the escape hatch.
Wire format unchanged. No AdCP version bump.
A reference GCP KMS adapter ships at
examples/gcp-kms-signing-provider.ts,type-checked under
npm run typecheck:examples. AWS KMS and Azure Key Vaultadapters can mirror the same pattern; users
npm ithe cloud SDK they need.See Add SigningProvider abstraction for external key management (KMS/HSM/Vault) #1009.
Patch Changes
a8e50ac: fix(hints): drop AJV-prose fallback in
groupRequiredIssuesMissingRequiredFieldHint.missing_fieldsis documented as "Field name(s) the parent object was required to carry." When the field-name extraction regex did not match an AJVrequirederror message (e.g. a reworded or locale-variant message), the fallback?? issue.messagewrote the entire AJV prose string intomissing_fields[]as if it were a field name. Downstream renderers (CLI, Addie, JUnit) wrap entries in backticks and generate "add the X field" coaching, so they would produce nonsense output for these entries.The fallback is now removed. When the regex does not match, the issue is skipped —
missing_fieldscontains only clean field identifiers. Unextractable issues remain visible viaValidationResult.warning.976c6e0: docs(testing): add @provenance annotations to StoryboardStepHint fields
Each field on the five hint kinds (ContextValueRejectedHint, ShapeDriftHint,
MissingRequiredFieldHint, FormatMismatchHint, MonotonicViolationHint) now
carries a @provenance seller|storyboard|runner tag so downstream renderers
(Addie, CLI, JUnit) can identify which fields contain seller-controlled bytes
that must be sanitized before reaching prompt-injection-vulnerable surfaces.
Also annotates StoryboardStepHintBase.message with an explicit warning that
the pre-formatted string embeds seller bytes for context_value_rejected and
monotonic_violation kinds; and adds @provenance to typedoc.json blockTags so
the TypeDoc build recognises the new tag.
Motivated by adcp#3084 and adcp#3220, where undocumented seller provenance on
request_field and from_status produced prompt-injection vectors in downstream
renderers.