Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ jobs:
working-directory: adcp/v3
run: go test -race -count=1 ./...

# Unit tests only (sqlmock-backed) — no Docker in this job. The
# Postgres-testcontainer suite is gated behind `-tags=integration`
# (see adcp/v3/signing/pgreplay/README.md) and runs locally / wherever
# Docker is available, matching registry/redisstore's and
# registry/glidestore's integration test convention.
- name: Test adcp/v3/signing/pgreplay module
working-directory: adcp/v3/signing/pgreplay
run: go test -race -count=1 ./...

- name: Test targeting module
working-directory: targeting
run: go test -race -count=1 ./...
Expand Down
6 changes: 5 additions & 1 deletion adcp/v3/signing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ For custom deployments, implement the two-method `JWKSResolver` interface.

## Replay cache

`NewMemoryReplayStore(perKeyIDCap)` — LRU with TTL eviction and the profile's per-keyid entry cap (default 1,000,000). Implement the three-method `ReplayStore` interface to plug in Redis or another shared store for distributed deployments; the spec requires the step-13 insert to be atomic with a cap check in distributed setups to prevent cap drift.
`NewMemoryReplayStore(perKeyIDCap)` — LRU with TTL eviction and the profile's per-keyid entry cap (default 1,000,000). Fine for a single verifier process; a per-process cache cannot enforce RFC 9421 §11.1's replay-rejection MUST once a verifier runs as more than one process behind a load balancer, since a captured signature replayed against a sibling instance's cache is accepted.

For multi-instance deployments, use [`adcp/v3/signing/pgreplay`](pgreplay/) — a Postgres-backed `ReplayStore` (own `go.mod`, zero third-party deps beyond `database/sql` in production code) that every verifier instance shares via one `adcp_replay_cache` table, closing that gap. See its package doc for the eager-connection-probe behavior and the test-vs-production wiring pattern.

Implementing the three-method `ReplayStore` interface directly also plugs in Redis or another shared store; the spec requires the step-13 insert to be atomic with a cap check in distributed setups to prevent cap drift.

## Key generation

Expand Down
112 changes: 112 additions & 0 deletions adcp/v3/signing/pgreplay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# adcp/v3/signing/pgreplay

Postgres-backed `adcp/v3/signing.ReplayStore` for multi-instance RFC 9421 verifier deployments.

Closes [adcontextprotocol/adcp-go#105](https://github.com/adcontextprotocol/adcp-go/issues/105) and [#54](https://github.com/adcontextprotocol/adcp-go/issues/54). Cross-validated against the JS SDK's `PostgresReplayStore` ([adcp-client#1018](https://github.com/adcontextprotocol/adcp-client/pull/1018), in production at agenticadvertising.org) and Python's `adcp-client-python` (`src/adcp/signing/pg/replay_store.py`) — same `(keyid, scope, nonce)` schema, same atomic `INSERT ... ON CONFLICT DO NOTHING` idiom.

**Module note:** both issues' text names the pre-v3 path (`adcp/signing/replay.go`). The root README's "Modules & versioning" section and `MIGRATING.md` say that module is frozen at v2.1.1, security-backports-only — `adcp/v3/signing` is the actively developed one, so this package lives there. `PostgresReplayStore` doesn't import either `signing` package (see below), so it's a structurally valid `ReplayStore` for the frozen `adcp/signing` too, byte-for-byte identical to `adcp/v3/signing.ReplayStore`'s method set as of this writing, if you haven't migrated to v3 yet.

## Why a separate module

`adcp/v3/signing.MemoryReplayStore` dedups `(keyid, nonce)` per process. That's sufficient for one verifier process; it can't deliver RFC 9421 §11.1's replay-rejection MUST once a verifier runs as ≥2 processes behind a load balancer — a captured signature replayed against a sibling instance whose in-memory cache hasn't seen the nonce is accepted. `PostgresReplayStore` gives every instance one shared table instead of N independent caches.

This directory is its own Go module. Production code here imports only `database/sql` — no driver is linked in, so it doesn't strictly *need* isolation to stay dependency-free (`adcp/idempotency`'s own Postgres adapter lives inside the shared `adcp` module today, for exactly that reason: `database/sql` alone doesn't threaten the zero-dep guarantee). The module boundary exists because #54 asked for one explicitly, as a structural, compiler-enforced guarantee that this package can never accidentally grow a real dependency that leaks into core `signing`'s import graph.

## Install

```bash
cd your-project
go get github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay
```

Bring your own driver (`github.com/jackc/pgx/v5/stdlib`, `github.com/lib/pq`, ...).

## Usage

```go
import (
"database/sql"

_ "github.com/jackc/pgx/v5/stdlib"
"github.com/adcontextprotocol/adcp-go/adcp/v3/signing"
"github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay"
)

db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}

// Apply once via your migration tooling:
// pgreplay.GetReplayStoreMigration()

replay := pgreplay.NewPostgresReplayStore(db) // panics if db is unreachable

mw := signing.Middleware(signing.MiddlewareOptions{
Resolver: resolver,
Replay: replay, // satisfies signing.ReplayStore
// ...
})
```

Run `pgreplay.SweepExpiredReplays(ctx, db)` on a cron / `pg_cron` job — this package doesn't schedule sweeping itself (out of scope per #105).

## Test and local-dev environments

`NewPostgresReplayStore` probes the connection eagerly and **panics** if it can't reach Postgres. That's deliberate — see the package doc comment for the production incident (adcp#3379) this is closing. **Do not construct a `PostgresReplayStore` in tests or local dev without a real reachable Postgres.** Use `signing.NewMemoryReplayStore(0)` there, gated behind an explicit production/staging environment check:

```go
func replayStore() signing.ReplayStore {
if os.Getenv("ENVIRONMENT") != "production" && os.Getenv("ENVIRONMENT") != "staging" {
return signing.NewMemoryReplayStore(0)
}
db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
if err != nil {
log.Fatalf("replay store: %v", err)
}
return pgreplay.NewPostgresReplayStore(db)
}
```

## Distinguishing "rejected" from "database is down"

`ReplayStore.Insert` returns a single `bool`, so this package's `Insert` — like `MemoryReplayStore.Insert` — cannot tell a caller "cap rejected" apart from "couldn't reach the DB" through its return value alone ([#54](https://github.com/adcontextprotocol/adcp-go/issues/54) raised this). `PostgresReplayStore` fails closed (returns `false`, i.e. reject) in both cases — a database outage must not silently disable replay rejection. To keep visibility into *which* case happened without changing the shared interface:

- `InsertContext(ctx, keyid, nonce, ttl) (bool, error)` — returns a non-nil error, wrapping `ErrConnDown`, only when the Postgres round trip itself failed.
- `LastInsertError()`, `LastSeenError()`, `LastHitCapError()` — the most recent round-trip error observed by each `ReplayStore`-interface method, for health checks / alerting.

Widening `signing.ReplayStore.Insert` itself to `(bool, error)` was considered and deliberately left out of this PR — see the PR description for the reasoning (it's a public interface with implementers outside this repo; a signature change here can't be verified against them). It's a natural, separately-reviewable follow-up.

## Schema

```sql
CREATE TABLE adcp_replay_cache (
keyid TEXT NOT NULL,
scope TEXT NOT NULL,
nonce TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (keyid, scope, nonce)
);
CREATE INDEX idx_adcp_replay_cache_expires_at ON adcp_replay_cache(expires_at);
CREATE INDEX idx_adcp_replay_cache_keyid_scope_active ON adcp_replay_cache(keyid, scope, expires_at);
```

`scope` lets one Postgres pool back more than one RFC 9421 tag profile (`adcp/request-signing/v1`, `adcp/webhook-signing/v1`) without nonce-namespace collisions — construct one store per profile via `pgreplay.WithScope(profile.Tag)`. Defaults to `"default"`.

## Testing

```bash
go test -race -count=1 ./... # unit tests (sqlmock; no Docker needed)
go test -race -tags=integration -count=1 -v ./... # + real Postgres via testcontainers-go; needs Docker
```

The integration suite proves, against a live `postgres:16-alpine` container:

- **The actual race this package exists to close**: 50 concurrent `Insert` calls for the identical `(keyid, scope, nonce)` yield exactly one winner.
- The `Seen`-then-`Insert` verifier flow under concurrency still yields exactly one winner at the `Insert` step even when multiple callers pass the `Seen` pre-check.
- `HitCap` is enforced against rows `Insert` actually wrote.
- `SweepExpiredReplays` removes only expired rows, leaving live ones untouched.
- `NewPostgresReplayStore` panics against a real (closed) connection, not just a mocked one.
- Two stores on the same database but different `scope`s don't observe each other's nonces.

Integration tests are skipped, not failed, when Docker isn't reachable (`t.Skipf`), matching `registry/redisstore`'s and `registry/glidestore`'s convention. They are not wired into `ci.yml`'s default `go test ./...` step — `-tags=integration` opts in explicitly, same as those two packages today.
103 changes: 103 additions & 0 deletions adcp/v3/signing/pgreplay/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Package pgreplay implements a Postgres-backed adcp/v3/signing.ReplayStore
// for multi-instance RFC 9421 verifier deployments.
//
// # Which module this targets
//
// This package lives under adcp/v3/signing, the actively developed module
// for AdCP 3.x (see the repo root README's "Modules & versioning" section
// and MIGRATING.md): the legacy adcp/signing module is frozen at v2.1.1 and
// receives security backports only, and a new distributed-store feature is
// not a security backport. Both #105 and #54's issue text name the pre-v3
// path (adcp/signing/replay.go) — written before the v3 split, the same way
// #53 did (see adcp/v3/signing/signingtest's own doc comment for that
// precedent). PostgresReplayStore doesn't import either signing package
// (see "Module boundary" below), so it is structurally a drop-in
// implementation of adcp/signing.ReplayStore too, byte-for-byte identical to
// adcp/v3/signing.ReplayStore's method set as of this writing — nothing
// here prevents wiring it into the frozen module if you're not yet on v3.
//
// # Why this exists
//
// adcp/v3/signing.MemoryReplayStore dedups (keyid, nonce) pairs in a
// per-process map. That is sufficient for a single verifier process, but RFC
// 9421 §11.1 makes replay rejection a MUST, and a per-process cache cannot
// deliver it once a verifier runs as more than one process behind a load
// balancer: a captured signature replayed against a sibling instance whose
// cache hasn't seen the nonce is accepted. PostgresReplayStore closes that
// gap by giving every verifier instance one shared table
// (adcp_replay_cache) instead of N independent in-memory caches.
//
// See https://github.com/adcontextprotocol/adcp-go/issues/105 and
// https://github.com/adcontextprotocol/adcp-go/issues/54, and the reference
// implementations this package is cross-validated against: the JS SDK's
// PostgresReplayStore (adcp-client#1018, adopted in production at
// agenticadvertising.org) and Python's adcp-client-python
// (src/adcp/signing/pg/replay_store.py). All three share the
// (keyid, scope, nonce) primary key and the same atomic
// INSERT ... ON CONFLICT DO NOTHING idiom for the nonce race.
//
// # Module boundary
//
// This package is its own Go module (this directory has its own go.mod)
// rather than living inside adcp/v3/signing or the shared adcp/v3 module.
// Production code here imports only database/sql from the standard library
// — no Postgres driver is linked in; callers supply an already-open *sql.DB
// wired to whichever driver they prefer (pgx stdlib adapter, lib/pq, ...).
// That means a separate module isn't required to keep production code
// dependency-free — database/sql alone would do that even living inside
// adcp/v3/signing, the way adcp/idempotency's PgBackend does today (it lives
// in the shared adcp module with zero external deps in its non-test files).
// This package uses a separate module anyway because
// https://github.com/adcontextprotocol/adcp-go/issues/54 asked for one
// explicitly, as a structural, compiler-enforced guarantee that pgreplay can
// never accidentally grow a real dependency (a pgx-specific error type
// check, say) that leaks into core signing's import graph. Test-only
// dependencies (testcontainers-go, go-sqlmock, a driver for integration
// tests) are scoped to this module's go.mod and never reach a consumer that
// only imports the package, exactly as with adcp/idempotency's test-only
// go-sqlmock dependency today.
//
// # Test environments — read this before wiring PostgresReplayStore anywhere
//
// NewPostgresReplayStore probes the connection eagerly and panics with an
// actionable message if it cannot reach Postgres. That is deliberate: the
// JS rollout (adcp#3379) learned that when a PostgresReplayStore is
// constructed against a pool that doesn't exist in the current environment,
// every signed request fails closed identically to "the verifier is
// broken" — a debugging trap if it isn't caught at wire-up. Failing fast in
// the constructor turns that into a startup-time error instead of a
// runtime mystery.
//
// The consequence: do not construct a PostgresReplayStore in tests, local
// dev, or any environment without a real reachable Postgres. Use
// signing.NewMemoryReplayStore(0) there. A typical wiring pattern:
//
// func replayStore() signing.ReplayStore {
// if os.Getenv("ENVIRONMENT") != "production" && os.Getenv("ENVIRONMENT") != "staging" {
// return signing.NewMemoryReplayStore(0)
// }
// db, err := sql.Open("pgx", os.Getenv("REPLAY_DATABASE_URL"))
// if err != nil {
// log.Fatalf("replay store: %v", err)
// }
// return pgreplay.NewPostgresReplayStore(db)
// }
//
// Gate the Postgres path on an explicit production/staging check, not on
// "did REPLAY_DATABASE_URL happen to get set" — a misconfigured production
// deployment should fail loudly, not silently fall back to a per-process
// cache that reintroduces the multi-instance replay gap. This mirrors the
// gated fallback the JS adopter shipped (getReplayStore(), gated on
// NODE_ENV !== 'production').
//
// # Fail-closed posture
//
// HitCap, Seen, and Insert all fail closed: any Postgres error (connection
// down, query timeout, context canceled) is treated as "reject this
// request," never as "allow it through." This matches RFC 9421 §11.1's MUST
// and this SDK's broader safety-over-availability posture — an outage in
// the replay store degrades to rejecting signed requests, not to accepting
// unverified replays. See the doc comments on Insert and LastInsertError for
// how a caller can distinguish a legitimate rejection from a database
// outage without changing the fail-closed behavior itself.
package pgreplay
67 changes: 67 additions & 0 deletions adcp/v3/signing/pgreplay/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module github.com/adcontextprotocol/adcp-go/adcp/v3/signing/pgreplay

go 1.26.2

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/jackc/pgx/v5 v5.10.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.44.0
)

require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading