Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ The targeting engine (`targeting/`) is the shared evaluation core. Reference age
| `targeting/engine.go` | Evaluation pipeline — all targeting logic lives here. |
| `targeting/store.go` | `Store` interface — abstracts Valkey. |
| `targeting/prommetrics/` | Stdlib-only Prometheus text format implementation. |
| `router/router.go` | Fan-out, merge, signing, circuit breaker. Embeddable via `RouterOption`. |
| `router/router.go` | Fan-out, merge, circuit breaker. Embeddable via `RouterOption`. TMP request signing wired through `WithTMPSigner` (see `router/signing.go` and the spec's [Request Authentication](https://adcontextprotocol.org/docs/trusted-match/specification#request-authentication) section). |
| `router/signing.go` | Router-side TMP signing — per-provider header attachment, context-match signature cache. |
| `tmproto/signing.go` | TMP request-authentication envelope (Ed25519, `X-AdCP-Signature`/`X-AdCP-Key-Id`, JCS for identity match, daily-epoch replay window). |
| `tmproto/verify_middleware.go` | `VerifyContextMatchHandler` / `VerifyIdentityMatchHandler` middleware used by reference providers. |
| `tmproto/keystore_remote.go` | `RemoteKeyStore` polls the router's `/registry/snapshot` for signing keys. |
| `router/serverconfig.go` | Config loading (JSON file, env vars, defaults). |
| `cmd/router/main.go` | Router binary entry point — wires components, Prometheus metrics, env vars. |
| `docs/network-surface.md` | Port map, data flow, pinhole spec, env var reference. |
Expand Down
2 changes: 1 addition & 1 deletion adcp/schemas/.bundle-sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b0cc315e39e0d125ad4c58054e8f68ddd26f93f2dbfda1ed8b7ec2272ef5632a
ea21c1297ad4c731710e27a6a2e14a6a8051ceb032b8e389a874e60b06d5b34a
2 changes: 1 addition & 1 deletion adcp/schemas/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.6
3.0.7
2 changes: 1 addition & 1 deletion adcp/types_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions cmd/router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ require (
github.com/adcontextprotocol/adcp-go/targeting/prommetrics v0.0.0
)

require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)

replace (
github.com/adcontextprotocol/adcp-go => ../../
github.com/adcontextprotocol/adcp-go/targeting/prommetrics => ../../targeting/prommetrics
Expand Down
4 changes: 4 additions & 0 deletions cmd/router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
104 changes: 102 additions & 2 deletions cmd/router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/adcontextprotocol/adcp-go/router"
"github.com/adcontextprotocol/adcp-go/targeting/prommetrics"
"github.com/adcontextprotocol/adcp-go/tmproto"
)

var version = "dev"
Expand All @@ -33,9 +37,28 @@ func main() {
registry := router.NewRegistry("", "")
health := router.NewProviderHealth(cfg.Health.FailureThreshold, time.Duration(cfg.Health.CooldownSeconds)*time.Second)
fanOutMetrics := &fanOutMetricsAdapter{} // set after metrics registry is created
r, err := router.NewRouter(cfg.Providers, registry, health,

signer, signerErr := loadSigner(&cfg.Signing)
if signerErr != nil {
slog.Error("invalid signing configuration", "error", signerErr)
os.Exit(1)
}
if signer != nil {
jwk := signer.PublicJWK()
// Seed the registry with property records the operator authorized us
// to sign for, so providers fetching /registry/snapshot pick up the
// public key alongside the property metadata.
seedSigningProperties(registry, cfg.Signing.PropertyRIDs, jwk)
}

routerOpts := []router.RouterOption{
router.WithLatencyBudget(cfg.LatencyBudget()),
router.WithFanOutMetrics(fanOutMetrics))
router.WithFanOutMetrics(fanOutMetrics),
}
if signer != nil {
routerOpts = append(routerOpts, router.WithTMPSigner(signer))
}
r, err := router.NewRouter(cfg.Providers, registry, health, routerOpts...)
if err != nil {
slog.Error("invalid router configuration", "error", err)
os.Exit(1)
Expand Down Expand Up @@ -177,9 +200,86 @@ func loadConfig(configFile, addr string) *router.ServerConfig {
cfg.Addr = envAddr
}

// Signing config — env vars override JSON, flags take precedence above
// neither (the router has no signing flags today).
if v := os.Getenv("TMP_ROUTER_SIGNING_KID"); v != "" {
cfg.Signing.KeyID = v
}
if v := os.Getenv("TMP_ROUTER_SIGNING_KEY_PATH"); v != "" {
cfg.Signing.PrivateKeyPath = v
}
if v := os.Getenv("TMP_ROUTER_SIGNING_PROPERTY_RIDS"); v != "" {
cfg.Signing.PropertyRIDs = splitAndTrim(v)
}
if v := os.Getenv("TMP_ROUTER_SIGNING_DISABLED"); v == "1" || strings.EqualFold(v, "true") {
cfg.Signing.Disabled = true
}

return cfg
}

func splitAndTrim(s string) []string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

// loadSigner builds a tmproto.Signer from the signing config, fail-closed when
// the operator has not provided a key and has not explicitly opted out.
func loadSigner(cfg *router.SigningConfig) (*tmproto.Signer, error) {
if cfg.Disabled {
slog.Warn("TMP request signing is disabled — fan-outs to spec-conformant providers will be rejected", "set_to_enable", "TMP_ROUTER_SIGNING_KEY_PATH")
return nil, nil
}
if cfg.KeyID == "" || cfg.PrivateKeyPath == "" {
return nil, errors.New("signing.key_id and signing.private_key_path are required (or set signing.disabled=true / TMP_ROUTER_SIGNING_DISABLED=true to opt out)")
}
pemBytes, err := os.ReadFile(cfg.PrivateKeyPath) //nolint:gosec // path is from operator config
if err != nil {
return nil, fmt.Errorf("read signing key %q: %w", cfg.PrivateKeyPath, err)
}
priv, err := tmproto.LoadEd25519PrivateKeyPEM(pemBytes)
if err != nil {
return nil, fmt.Errorf("parse signing key %q: %w", cfg.PrivateKeyPath, err)
}
signer, err := tmproto.NewSigner(cfg.KeyID, priv)
if err != nil {
return nil, err
}
slog.Info("TMP signer loaded", "kid", cfg.KeyID, "properties", len(cfg.PropertyRIDs))
return signer, nil
}

// seedSigningProperties ensures every authorized property RID has a record in
// the registry with the router's public key attached. Records that don't exist
// yet (typical when running without a registry sync source) are created with
// just the RID + signing key so downstream providers can resolve the kid.
func seedSigningProperties(registry *router.Registry, propertyRIDs []string, jwk tmproto.SigningKey) {
if len(propertyRIDs) == 0 {
return
}
for _, rid := range propertyRIDs {
if _, ok := registry.LookupByRID(rid); !ok {
registry.ApplyUpdate(&router.RegistryUpdate{
Sequence: registry.Sequence() + 1,
Action: "add",
Property: router.RegistryProperty{
PropertyRID: rid,
PropertyID: rid, // placeholder until registry sync provides a slug
},
})
}
if !registry.AttachSigningKey(rid, jwk) {
slog.Warn("could not attach signing key to property", "property_rid", rid)
}
}
}

// healthCheckMetricsAdapter bridges router.HealthCheckMetrics to prommetrics.
type healthCheckMetricsAdapter struct {
reg *prommetrics.Registry
Expand Down
100 changes: 84 additions & 16 deletions docs/network-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ AgenticAdvertising.org ◄── Registry Syncer (outbound HTTPS polling)
### Context Match

1. Publisher client sends `POST /tmp/context` to router with `property_id`, `placement_id`, `available_packages`, `artifacts`
2. Router enriches request: resolves `property_rid` from registry, computes URL hash, signs with Ed25519
3. Router fans out to matching context agents in parallel (30ms timeout per provider)
4. Each context agent evaluates: property bitmap → suppression → signature → URL filter → topic match
2. Router enriches request: resolves `property_rid` from registry, computes URL hash, signs per provider with Ed25519 (`X-AdCP-Signature` / `X-AdCP-Key-Id`)
3. Router fans out to matching context agents in parallel (30ms timeout per provider). Signature is reused across requests for the same `(placement_id, provider, epoch)` from the in-process cache.
4. Each context agent verifies the signature against the router's published key, then evaluates: property bitmap → suppression → URL filter → topic match
5. Router merges offers and signals from all agents
6. Response to publisher: offers + signals

Expand All @@ -78,12 +78,13 @@ AgenticAdvertising.org ◄── Registry Syncer (outbound HTTPS polling)
### Identity Match

1. Publisher client sends `POST /tmp/identity` to router with `user_token` (or `identities`), `package_ids`, `country`
2. Router filters providers by `country` and `uid_type`, strips `country` before forwarding
3. Router fans out to matching identity agents (30ms timeout)
4. Each identity agent evaluates: campaign freq cap → package freq cap → audience → intent score, returns TMPX token
5. Router merges eligible package lists (union — packages are provider-specific)
6. Router collects TMPX tokens into `tmpx_providers` map keyed by provider ID
7. Response to publisher: eligible package ID list + TTL + provider-keyed TMPX tokens
2. Router filters providers by `country` and `uid_type`, strips `country` before forwarding (the country is not part of the signing input)
3. Router signs per provider with Ed25519 — each signature binds to the provider's registered endpoint URL (a signature minted for provider A is rejected by provider B)
4. Router fans out to matching identity agents (30ms timeout)
5. Each identity agent verifies the signature, then evaluates: campaign freq cap → package freq cap → audience → intent score, returns TMPX token
6. Router merges eligible package lists (union — packages are provider-specific)
7. Router collects TMPX tokens into `tmpx_providers` map keyed by provider ID
8. Response to publisher: eligible package ID list + TTL + provider-keyed TMPX tokens

### Exposure Tracking (TMPX)

Expand All @@ -94,6 +95,34 @@ Exposure tracking uses encrypted TMPX tokens instead of a dedicated endpoint:
3. Publisher substitutes provider-specific TMPX values into creative tracking URLs (e.g., `{TMPX_S3}`)
4. Buyer's impression pixel receives the token, decrypts it, and updates per-user frequency state

**Cipher suite (fixed by spec):** HPKE `mode_base` with KEM=DHKEM(X25519, HKDF-SHA256), KDF=HKDF-SHA256, AEAD=ChaCha20-Poly1305. Implemented in `tmproto/tmpx.go` against stdlib (`crypto/ecdh`, `crypto/hkdf`, `crypto/sha256`) plus `golang.org/x/crypto/chacha20poly1305`; validated against the RFC 9180 §A.3 vector.

**Wire format:** `<kid>.<base64url_no_pad(enc || ciphertext_with_tag)>`. `kid` is opaque, ≤8 chars, MUST NOT encode geographic or deployment information.

**Plaintext layout (16-byte header + entries):**

| Field | Size | Notes |
|---|---|---|
| Version | 1 | `0x01` |
| Timestamp | 4 | Unix seconds, big-endian uint32 |
| Country | 2 | ISO 3166-1 alpha-2, ASCII; data-residency hint, buyer-internal |
| Nonce | 8 | Random; deduplication at the master |
| Count | 1 | Number of identity entries |
| Entries | variable | `type_id (1 byte) + token (size from registry)` |

**Reference identity-agent configuration:**

| Flag / env var | Purpose |
|---|---|
| `--tmpx-encrypt-jwks-url` / `TMP_IDENTITY_TMPX_ENCRYPT_JWKS_URL` | Buyer's JWKS endpoint advertising the TMPX recipient (X25519, `adcp_use=tmpx-encrypt`, `alg=HPKE-DHKEM-X25519-HKDF-SHA256`). The agent polls this on `--tmpx-encrypt-jwks-ttl` and picks the entry with the newest `iat` for sealing. |
| `--tmpx-encrypt-jwks-ttl` | JWKS poll interval (default 5 min — the spec's recommended cache TTL). |
| `--tmpx-country` / `TMP_IDENTITY_TMPX_COUNTRY` | Country stamped into the TMPX plaintext header. |
| `--tmpx-priority` / `TMP_IDENTITY_TMPX_PRIORITY` | Comma-separated UID type ordering used to truncate identities when the resolved set would exceed the 255-byte wire budget (e.g. `uid2,rampid,id5`). Without it, an over-budget set returns an error — the spec forbids arbitrary truncation. |

When the URL and country are set, the agent generates a TMPX token alongside every identity-match response that has at least one eligible package. The agent reads the `kid` from the currently-active JWKS entry on each seal, so buyer-side key rotation propagates automatically within the TTL window. Identity tokens whose `uid_type` has no entry in the TMPX type-ID registry are skipped per the spec's forward-compatibility rule.

**Reference-impl limitation:** the `string → binary token` conversion in the reference identity-agent is a SHA-512 truncation stub (`stubBinaryToken` in `cmd/identity-agent/main.go`). Real buyer deployments decode tokens per the source graph's encoding (UID2 base64, RampID Xi/XY format, MAID UUID parse, etc.). The reference output is **not** interoperable with a real buyer master — the agent refuses to start with TMPX configured unless `TMP_IDENTITY_TMPX_REFERENCE_STUB_ACK=1` is set.

## Pinhole Specification

The identity agent is the privacy boundary. When running in a TEE:
Expand Down Expand Up @@ -141,24 +170,63 @@ The router tracks per-provider health:
- Timeout and error both count as failures
- Success resets consecutive failure counter

## Ed25519 Signing
## Request Authentication (Ed25519)

The router signs every outbound `/tmp/context` and `/tmp/identity` request per the [TMP spec](https://adcontextprotocol.org/docs/trusted-match/specification#request-authentication). Providers verify the signature against the router's published public key (discovered via the registry) before evaluating the request.

**Headers attached to every fan-out:**

| Header | Value |
|---|---|
| `X-AdCP-Signature` | Ed25519 signature, base64url, no padding |
| `X-AdCP-Key-Id` | Key identifier (`kid`) used to sign |

**Signed inputs:**

- **Context match** — newline-joined: `context_match_request | property_rid | placement_id | sorted-comma-joined package_ids | provider_endpoint_url | daily_epoch`. Cached on the router per `(placement_id, provider_endpoint_url, epoch)` — context-match signing inputs are static across requests within an epoch.
- **Identity match** — `hex(SHA-256(JCS({type, request_id, identities_hash, consent, package_ids, provider_endpoint_url, daily_epoch})))`. Per-request, never cached. RFC 8785 JCS protects against delimiter-injection from arbitrary-byte fields like `consent.gpp`.

**Replay window:** `daily_epoch = floor(unix_timestamp / 86400)`. Verifiers accept signatures bound to current or previous epoch (~48h). Stale epochs are rejected.

**Per-provider binding:** every signature includes the registered `provider_endpoint_url`. A signature minted for provider A is rejected by provider B even with an identical body.

**Key distribution:** the router's public key is published as a `signing_keys` JWK on the property records served by `GET /registry/snapshot`. Reference providers poll the snapshot URL on a 5-minute interval (`tmproto.RemoteKeyStore`) and look up by `kid`. The keystore polls over HTTPS by default, denies cross-origin redirects, and limits snapshot bodies to 1 MB; plain-HTTP is opt-in via `RemoteKeyStoreOptions.AllowInsecureScheme` for local dev only.

**Revocation:** set `revoked_at` on the JWK. The verifier rejects any signature candidate whose daily epoch is at or after the revocation epoch — `e >= floor(revoked_at_unix / 86400)` — but the spec's two-epoch acceptance window means a signature minted on day N-1 with `revoked_at` on day N still verifies under the previous-epoch candidate up to ~24 hours after the revocation marker is published. Operators who need a hard cutoff should rotate the key (replacing the kid) rather than rely on revocation alone.

**Cross-property kid collision:** the registry and `RemoteKeyStore` both keep the first-seen entry on duplicate kids and warn — last-writer-wins would let one property's record shadow another's signing key namespace.

**Crypto agility:** the implementation pins one signature suite (Ed25519/EdDSA, JWK `kty=OKP, crv=Ed25519`) and one HPKE suite (X25519/HKDF-SHA256/ChaCha20-Poly1305) per the current spec. Adding a second suite requires extending the `signingAlgorithm`/`signingCurve` constants in `tmproto/signing.go`, the `hpke*` IDs in `tmproto/tmpx.go`, and dispatching by `kid` prefix or the JWK `alg`/`crv` fields. The structure assumes one suite at a time — there is no in-band negotiation.

**Configuration:**

- Router signs context match requests with Ed25519 private key
- Signature cached per `(placement_id, package_set_hash, epoch)`
- Epoch = 60 seconds; signatures valid for current + previous epoch
- Agents verify signatures using property's public key from registry
- Verification can be sampled (0-100% rate)
- Router: `TMP_ROUTER_SIGNING_KID`, `TMP_ROUTER_SIGNING_KEY_PATH` (PEM PKCS#8 Ed25519), `TMP_ROUTER_SIGNING_PROPERTY_RIDS` (comma-separated RIDs the router is authorized to sign for). Set `TMP_ROUTER_SIGNING_DISABLED=true` to opt out (dev only).
- Reference agents: `--registry-url` (default off — accepts unsigned), `--require-signature`, `--own-endpoint-url`. Env equivalents: `TMP_{IDENTITY,CONTEXT}_REGISTRY_URL`, `TMP_{IDENTITY,CONTEXT}_REQUIRE_SIGNATURE`, `TMP_{IDENTITY,CONTEXT}_ENDPOINT_URL`.

## Environment Variables

| Variable | Service | Purpose | Default |
|----------|---------|---------|---------|
| `TMP_ROUTER_ADDR` | Router | Listen address | `:8080` |
| `TMP_ROUTER_CONFIG` | Router | Path to JSON config file | (none) |
| `TMP_ROUTER_SIGNING_KID` | Router | Key identifier for outbound signatures | (none) |
| `TMP_ROUTER_SIGNING_KEY_PATH` | Router | PEM PKCS#8 Ed25519 private key path | (none) |
| `TMP_ROUTER_SIGNING_PROPERTY_RIDS` | Router | Comma-separated property RIDs the router signs for | (none) |
| `TMP_ROUTER_SIGNING_DISABLED` | Router | Disable request signing (dev only — fail-closed otherwise) | `false` |
| `TMP_CONTEXT_ADDR` | Context Agent | Listen address | `:8081` |
| `TMP_CONTEXT_REGISTRY` | Context Agent | Path to registry snapshot | (none) |
| `TMP_CONTEXT_REGISTRY` | Context Agent | Path to local registry snapshot | (none) |
| `TMP_CONTEXT_REGISTRY_URL` | Context Agent | URL of router's `/registry/snapshot` for signing keys | (none) |
| `TMP_CONTEXT_ENDPOINT_URL` | Context Agent | Own registered endpoint URL (signed-binding check) | (none) |
| `TMP_CONTEXT_REQUIRE_SIGNATURE` | Context Agent | Reject unsigned requests | `false` |
| `TMP_IDENTITY_ADDR` | Identity Agent | Listen address | `:8082` |
| `TMP_IDENTITY_REDIS_ADDR` | Identity Agent | Valkey/Redis address | (none, uses in-memory) |
| `TMP_IDENTITY_REGISTRY_URL` | Identity Agent | URL of router's `/registry/snapshot` for signing keys | (none) |
| `TMP_IDENTITY_ENDPOINT_URL` | Identity Agent | Own registered endpoint URL (signed-binding check) | (none) |
| `TMP_IDENTITY_REQUIRE_SIGNATURE` | Identity Agent | Reject unsigned requests | `false` |
| `TMP_IDENTITY_TMPX_ENCRYPT_JWKS_URL` | Identity Agent | Buyer JWKS URL publishing the TMPX recipient key | (none) |
| `TMP_IDENTITY_TMPX_COUNTRY` | Identity Agent | Country stamped into TMPX plaintext header | (none) |
| `TMP_IDENTITY_TMPX_PRIORITY` | Identity Agent | Comma-separated UID type priority for budget-driven truncation | (none) |
| `TMP_IDENTITY_TMPX_REFERENCE_STUB_ACK` | Identity Agent | Set to `1` to acknowledge the SHA-512 reference token stub | (none) |

All services also accept `--addr` and other flags. Flags take precedence over environment variables.

Expand Down
Loading
Loading