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
20 changes: 20 additions & 0 deletions .changeset/signing-defaults-and-preset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@adcp/client": minor
---

Four ergonomic upgrades to the RFC 9421 signing surface — all backwards compatible, all opt-in via omission:

- **`verifySignatureAsAuthenticator`** now defaults `replayStore` and `revocationStore` to fresh `InMemoryReplayStore` / `InMemoryRevocationStore` instances when omitted. Every authenticator instance gets its own default stores (no cross-talk). Wire explicit stores in multi-replica deployments where replay state must be shared.
- **`createExpressVerifier`** gets the same defaults — symmetric with `verifySignatureAsAuthenticator` so both the `serve()` and raw-Express paths have identical ergonomics.
- **`buildAgentSigningFetch`** now defaults `upstream` to `globalThis.fetch` when omitted. Throws a clear `TypeError` if `globalThis.fetch` isn't available, rather than binding `undefined` and failing cryptically on first request.
- **`createAgentSignedFetch(options)`** — new preset for the single-seller buyer case. Bundles `buildAgentSigningFetch` with a `CapabilityCache` lookup keyed by the target seller's `agent_uri`. One call replaces the four-object `buildAgentSigningFetch` + `CapabilityCache` + explicit `getCapability` wire-up:

```typescript
// fetch.ts
export const signedFetch = createAgentSignedFetch({
signing: { kid, alg: 'ed25519', private_key: privateJwk, agent_url: 'https://agent.example.com' },
sellerAgentUri: 'https://seller.example.com',
});
```

For multi-seller adapters, build one preset per seller or drop to `buildAgentSigningFetch` with a URL-dispatching `getCapability`.
33 changes: 25 additions & 8 deletions src/lib/server/auth-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import type { IncomingMessage } from 'http';
import type { RequestLike } from '../signing/canonicalize';
import { RequestSignatureError } from '../signing/errors';
import type { JwksResolver } from '../signing/jwks';
import type { ReplayStore } from '../signing/replay';
import type { RevocationStore } from '../signing/revocation';
import { InMemoryReplayStore, type ReplayStore } from '../signing/replay';
import { InMemoryRevocationStore, type RevocationStore } from '../signing/revocation';
import type { VerifiedSigner, VerifierCapability, VerifyResult } from '../signing/types';
import { verifyRequestSignature } from '../signing/verifier';
import {
Expand All @@ -62,10 +62,21 @@ export interface VerifySignatureAsAuthenticatorOptions {
capability: VerifierCapability;
/** Resolves verification keys by `keyid`. */
jwks: JwksResolver;
/** Stores `(keyid, signature-bytes, expires)` tuples for replay detection. */
replayStore: ReplayStore;
/** Consulted for revoked `kid` / `jti` before accepting a signature. */
revocationStore: RevocationStore;
/**
* Stores `(keyid, signature-bytes, expires)` tuples for replay detection.
* Defaults to a fresh {@link InMemoryReplayStore} — fine for single-process
* deployments. Wire a shared store (Redis, Postgres, etc.) for multi-replica
* setups where a signature accepted on one replica must be rejected on the
* others.
*/
replayStore?: ReplayStore;
/**
* Consulted for revoked `kid` / `jti` before accepting a signature.
* Defaults to a fresh {@link InMemoryRevocationStore}. Most agents don't
* revoke at runtime; when you do, swap in a store backed by your secrets
* manager or admin tooling.
*/
revocationStore?: RevocationStore;
/** Override clock for tests. */
now?: () => number;
/**
Expand Down Expand Up @@ -110,6 +121,12 @@ export interface VerifySignatureAsAuthenticatorOptions {
* same side-channel state as the Express-shaped middleware.
*/
export function verifySignatureAsAuthenticator(options: VerifySignatureAsAuthenticatorOptions): Authenticator {
// Instantiate defaults once at wire-up time so every request on this
// authenticator shares the same replay/revocation state — lazy
// per-request construction would defeat replay detection entirely.
const replayStore = options.replayStore ?? new InMemoryReplayStore();
const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();

const authenticator: Authenticator = async req => {
if (!hasSignatureHeader(req)) return null;

Expand All @@ -127,8 +144,8 @@ export function verifySignatureAsAuthenticator(options: VerifySignatureAsAuthent
result = await verifyRequestSignature(requestLike, {
capability: options.capability,
jwks: options.jwks,
replayStore: options.replayStore,
revocationStore: options.revocationStore,
replayStore,
revocationStore,
now: options.now,
operation: options.resolveOperation(req as IncomingMessage & { rawBody?: string }),
agentUrlForKeyid: options.agentUrlForKeyid,
Expand Down
111 changes: 108 additions & 3 deletions src/lib/signing/agent-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import type { AgentRequestSigningConfig } from '../types/adcp';
import { createSigningFetch, type CoverContentDigestPredicate } from './fetch';
import type { CachedCapability, CapabilityCache } from './capability-cache';
import {
buildCapabilityCacheKey,
defaultCapabilityCache,
type CachedCapability,
type CapabilityCache,
} from './capability-cache';
import type { ContentDigestPolicy, VerifierCapability } from './types';
import type { SignerKey } from './signer';

type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;

/**
* Resolve the default upstream fetch at call time rather than at module
* import so (a) polyfills / patches that run after this module loads still
* take effect, and (b) the helper throws a clear error on environments
* lacking global `fetch` instead of binding `undefined` at import time and
* failing cryptically on first request.
*/
function defaultUpstream(): FetchLike {
const f = (globalThis as { fetch?: FetchLike }).fetch;
if (typeof f !== 'function') {
throw new TypeError(
'buildAgentSigningFetch: no upstream fetch provided and globalThis.fetch is unavailable. Pass `upstream: yourFetch` explicitly.'
);
}
return f;
}

function bodyToUtf8(body: unknown): string | undefined {
if (body === undefined || body === null) return undefined;
if (typeof body === 'string') return body.length ? body : undefined;
Expand Down Expand Up @@ -137,7 +159,12 @@ export function toSignerKey(config: AgentRequestSigningConfig): SignerKey {
}

export interface BuildAgentSigningFetchOptions {
upstream: FetchLike;
/**
* Upstream fetch to wrap. Defaults to `globalThis.fetch` when omitted —
* use a Node 18+ / browser / worker global, or pass a polyfill / a
* decorated fetch (retries, telemetry) to compose.
*/
upstream?: FetchLike;
signing: AgentRequestSigningConfig;
/** Lazy accessor for the current cached capability — re-read on every call. */
getCapability: () => CachedCapability | undefined;
Expand All @@ -154,7 +181,8 @@ export interface BuildAgentSigningFetchOptions {
* 4. Delegate to `createSigningFetch` with the decision baked in.
*/
export function buildAgentSigningFetch(options: BuildAgentSigningFetchOptions): FetchLike {
const { upstream, signing, getCapability } = options;
const { signing, getCapability } = options;
const upstream = options.upstream ?? defaultUpstream();
const key = toSignerKey(signing);

const shouldSign = (_url: string, init: RequestInit | undefined): boolean => {
Expand All @@ -170,3 +198,80 @@ export function buildAgentSigningFetch(options: BuildAgentSigningFetchOptions):

return createSigningFetch(upstream, key, { shouldSign, coverContentDigest });
}

export interface CreateAgentSignedFetchOptions {
/** This agent's RFC 9421 signing identity (kid, alg, private_key, agent_url). */
signing: AgentRequestSigningConfig;
/**
* Target seller's `agent_uri` — the base URL whose `get_adcp_capabilities`
* response gates whether each operation gets signed. The preset keys a
* capability-cache entry off this URL so repeat calls reuse the same
* advertisement.
*
* For multi-seller buyers, build one signed fetch per seller (or use
* {@link buildAgentSigningFetch} directly and supply your own
* `getCapability` that dispatches on the target URL).
*/
sellerAgentUri: string;
/**
* Optional auth token the seller expects alongside the signing headers.
* Included in the capability cache key so a token rotation naturally
* invalidates the cached advertisement.
*/
sellerAuthToken?: string;
/** Capability cache. Defaults to the shared {@link defaultCapabilityCache}. */
cache?: CapabilityCache;
/** Upstream fetch. Defaults to `globalThis.fetch`. */
upstream?: FetchLike;
}

/**
* One-call preset for the single-seller case: bundles
* {@link buildAgentSigningFetch} with a {@link CapabilityCache} lookup keyed
* by `sellerAgentUri`, so adapter authors don't have to wire the cache and
* capability accessor themselves.
*
* ```ts
* // fetch.ts
* import { createAgentSignedFetch } from '@adcp/client/signing';
*
* export const signedFetch = createAgentSignedFetch({
* signing: {
* kid: 'my-agent-2026',
* alg: 'ed25519',
* private_key: JSON.parse(process.env.ADCP_PRIV_KEY!),
* agent_url: 'https://agent.example.com',
* },
* sellerAgentUri: 'https://seller.example.com',
* });
* ```
*
* ```ts
* // any other module
* import { signedFetch } from './fetch';
*
* await signedFetch('https://seller.example.com/mcp', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify(payload),
* });
* ```
*
* Signing only happens on operations the seller advertises as
* `required_for` / `warn_for` (or `supported_for` when the buyer config
* opts in) — so the `get_adcp_capabilities` priming call itself is always
* unsigned, as the spec requires.
*
* For multi-seller adapters, construct one preset per seller, or use
* {@link buildAgentSigningFetch} directly with a request-dispatching
* `getCapability` callback.
*/
export function createAgentSignedFetch(options: CreateAgentSignedFetchOptions): FetchLike {
const cache = options.cache ?? defaultCapabilityCache;
const cacheKey = buildCapabilityCacheKey(options.sellerAgentUri, options.sellerAuthToken);
return buildAgentSigningFetch({
signing: options.signing,
upstream: options.upstream,
getCapability: () => cache.get(cacheKey),
});
}
2 changes: 2 additions & 0 deletions src/lib/signing/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ export {
} from './capability-cache';
export {
buildAgentSigningFetch,
createAgentSignedFetch,
extractAdcpOperation,
resolveCoverContentDigest,
shouldSignOperation,
toSignerKey,
type BuildAgentSigningFetchOptions,
type CreateAgentSignedFetchOptions,
} from './agent-fetch';
export { buildAgentSigningContext, signingContextStorage, type AgentSigningContext } from './agent-context';
export { ensureCapabilityLoaded, CAPABILITY_OP } from './capability-priming';
30 changes: 29 additions & 1 deletion src/lib/signing/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { RequestLike } from './canonicalize';
import { getHeaderValue } from './canonicalize';
import { RequestSignatureError } from './errors';
import { InMemoryReplayStore } from './replay';
import { InMemoryRevocationStore } from './revocation';
import { verifyRequestSignature, type VerifyRequestOptions } from './verifier';
import type { VerifiedSigner } from './types';

Expand Down Expand Up @@ -29,7 +31,25 @@ export interface ExpressLike {
[key: string]: unknown;
}

export interface ExpressMiddlewareOptions extends Omit<VerifyRequestOptions, 'operation'> {
export interface ExpressMiddlewareOptions extends Omit<
VerifyRequestOptions,
'operation' | 'replayStore' | 'revocationStore'
> {
/**
* Stores `(keyid, signature-bytes, expires)` tuples for replay detection.
* Defaults to a fresh {@link InMemoryReplayStore} — fine for single-process
* deployments. Wire a shared store (Redis, Postgres, etc.) for multi-replica
* setups where a signature accepted on one replica must be rejected on the
* others.
*/
replayStore?: VerifyRequestOptions['replayStore'];
/**
* Consulted for revoked `kid` / `jti` before accepting a signature.
* Defaults to a fresh {@link InMemoryRevocationStore}. Most agents don't
* revoke at runtime; when you do, swap in a store backed by your secrets
* manager or admin tooling.
*/
revocationStore?: VerifyRequestOptions['revocationStore'];
/**
* Extract the AdCP operation name from the incoming request so the verifier
* can consult `capability.required_for`. Return `undefined` for requests
Expand Down Expand Up @@ -58,6 +78,12 @@ export interface ExpressMiddlewareOptions extends Omit<VerifyRequestOptions, 'op
type NextFn = (err?: unknown) => void;

export function createExpressVerifier(options: ExpressMiddlewareOptions) {
// Instantiate defaults once at wire-up so every request on this middleware
// shares the same replay/revocation state — lazy per-request construction
// would defeat replay detection entirely.
const replayStore = options.replayStore ?? new InMemoryReplayStore();
const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();

return async function requestSignatureMiddleware(
req: ExpressLike,
res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } },
Expand All @@ -74,6 +100,8 @@ export function createExpressVerifier(options: ExpressMiddlewareOptions) {
};
const result = await verifyRequestSignature(requestLike, {
...options,
replayStore,
revocationStore,
operation: options.resolveOperation(req),
});
if (result.status === 'verified') {
Expand Down
Loading
Loading