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
18 changes: 18 additions & 0 deletions .changeset/request-signing-rfc-9421.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@adcp/client': minor
---

RFC 9421 request-signing profile (AdCP 3.0 optional). Adds `@adcp/client/signing`
with signer, verifier, Express-shaped middleware, pluggable JWKS/replay/revocation
stores, and typed error taxonomy (`RequestSignatureError`). Passes all 28 spec
conformance vectors shipped in `compliance/cache/latest/test-vectors/request-signing/`
(one positive vector currently skipped pending upstream adcp#2335 tarball
republish — test auto-unskips when `npm run sync-schemas` pulls the fixed
vector). Verifier uses the received `Signature-Input` substring verbatim when
rebuilding the signature base, so peers emitting params in any legal RFC 8941
order remain byte-identical. Replay TTL floored at one max-window + skew so
short-validity signers can't escape the replay horizon. Content-Digest parses
as an RFC 9530 dictionary (accepts `sha-256` alongside other algorithms).
JWKS-returns-wrong-kid and Content-Length-without-rawBody both reject as typed
errors. New CLI: `adcp signing generate-key` (suppresses private JWK from
stdout when `--private-out` is set) and `adcp signing verify-vector`.
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,80 @@ const client = new ADCPMultiAgentClient(agents, {
// Returns 401 if signature invalid
```

### Request Signing (RFC 9421, AdCP 3.0 optional)

`@adcp/client/signing` implements the [AdCP request-signing transport profile](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer)
— HTTP Message Signatures (RFC 9421) with Ed25519 (default) or ES256, a `adcp/request-signing/v1` tag,
and content-digest support. Conformance is verified against all 28 vectors shipped in the spec repo
(see `compliance/cache/latest/test-vectors/request-signing/`).

**Generate a signing key + JWKS for publication:**

```bash
adcp signing generate-key --alg ed25519 --kid my-buyer-2026 \
--private-out ./buyer.pem --public-out ./buyer-jwks.json
# Publish buyer-jwks.json at the jwks_uri of your brand.json agents[] entry.
```

**Signer (client-side) — wraps `fetch` to sign outbound requests:**

```typescript
import { createSigningFetch } from '@adcp/client/signing';

const signingFetch = createSigningFetch(fetch, {
keyid: 'my-buyer-2026',
alg: 'ed25519',
privateKey: buyerPrivateJwk, // JWK with `d` field
});

await signingFetch('https://seller.example.com/adcp/create_media_buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_id: 'plan_001' }),
});
// Signature, Signature-Input, and (optionally) Content-Digest headers
// are attached per RFC 9421 before the upstream fetch is called.
```

**Verifier (server-side) — middleware runs the 12-step checklist:**

```typescript
import {
createExpressVerifier,
InMemoryReplayStore,
InMemoryRevocationStore,
StaticJwksResolver,
} from '@adcp/client/signing';

app.post(
'/adcp/create_media_buy',
rawBodyMiddleware(), // req.rawBody must hold the byte-exact body
createExpressVerifier({
capability: {
supported: true,
covers_content_digest: 'required', // 'required' | 'forbidden' | 'either'
required_for: ['create_media_buy'],
},
jwks: new StaticJwksResolver(publishedBuyerKeys),
replayStore: new InMemoryReplayStore(),
revocationStore: new InMemoryRevocationStore(),
resolveOperation: (req) => 'create_media_buy',
}),
handler,
);
// On verify, req.verifiedSigner = { keyid, agent_url?, verified_at }.
// On reject, the middleware returns 401 with
// WWW-Authenticate: Signature error="<code>"
// and JSON { error, message, failed_step }.
```

**Running a single vector for debugging:**

```bash
adcp signing verify-vector \
--vector compliance/cache/latest/test-vectors/request-signing/positive/001-basic-post.json
```

### Authentication

```typescript
Expand Down
233 changes: 233 additions & 0 deletions bin/adcp-signing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#!/usr/bin/env node

const { generateKeyPairSync } = require('node:crypto');
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
const path = require('node:path');

const {
InMemoryReplayStore,
InMemoryRevocationStore,
RequestSignatureError,
StaticJwksResolver,
verifyRequestSignature,
} = require('../dist/lib/signing/index.js');

function generateKey(argv) {
let alg = 'ed25519';
let kid;
let outPrivate;
let outPublic;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--alg') alg = argv[++i];
else if (a === '--kid') kid = argv[++i];
else if (a === '--out' || a === '--private-out') outPrivate = argv[++i];
else if (a === '--public-out' || a === '--jwk-out') outPublic = argv[++i];
else if (a === '--help' || a === '-h') {
printGenerateHelp();
process.exit(0);
} else {
console.error(`Unknown flag: ${a}`);
printGenerateHelp();
process.exit(2);
}
}
if (alg !== 'ed25519' && alg !== 'es256') {
console.error(`--alg must be ed25519 or es256 (got ${alg})`);
process.exit(2);
}
if (!kid) {
const year = new Date().getUTCFullYear();
kid = `adcp-${alg}-${year}`;
}

const { publicKey, privateKey } =
alg === 'ed25519' ? generateKeyPairSync('ed25519') : generateKeyPairSync('ec', { namedCurve: 'P-256' });

const jwkAlg = alg === 'ed25519' ? 'EdDSA' : 'ES256';
const privateJwk = privateKey.export({ format: 'jwk' });
const publicJwk = publicKey.export({ format: 'jwk' });
publicJwk.kid = kid;
publicJwk.alg = jwkAlg;
publicJwk.use = 'sig';
publicJwk.key_ops = ['verify'];
publicJwk.adcp_use = 'request-signing';

const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });

if (outPrivate) {
writeFileSync(outPrivate, privatePem, { mode: 0o600 });
console.log(`✔ Private key written: ${outPrivate}`);
} else {
process.stdout.write(privatePem);
}

const publicJson = JSON.stringify({ keys: [publicJwk] }, null, 2);
if (outPublic) {
writeFileSync(outPublic, publicJson);
console.log(`✔ JWKS written: ${outPublic}`);
} else {
console.log('\n// Publish this JWKS at the jwks_uri of your agents[] entry:');
console.log(publicJson);
}
// Print the private JWK to stdout ONLY when the user explicitly wants it
// rendered (no --private-out). Otherwise the private key has already been
// written to a 0600 file and we must not leak it into terminal/shell history.
if (!outPrivate) {
privateJwk.kid = kid;
privateJwk.alg = jwkAlg;
privateJwk.use = 'sig';
privateJwk.key_ops = ['sign'];
privateJwk.adcp_use = 'request-signing';
console.log(
`\n// Private JWK (keep secret — load via { signing_key, keyid: "${kid}", alg: "${alg === 'ed25519' ? 'ed25519' : 'ecdsa-p256-sha256'}" }):`
);
console.log(JSON.stringify(privateJwk, null, 2));
}
}

async function verifyVector(argv) {
let vectorPath;
let keysPath;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--vector') vectorPath = argv[++i];
else if (a === '--keys') keysPath = argv[++i];
else if (a === '--help' || a === '-h') {
printVerifyHelp();
process.exit(0);
} else if (!vectorPath) vectorPath = a;
else {
console.error(`Unknown argument: ${a}`);
printVerifyHelp();
process.exit(2);
}
}
if (!vectorPath) {
printVerifyHelp();
process.exit(2);
}
if (!existsSync(vectorPath)) {
console.error(`Vector file not found: ${vectorPath}`);
process.exit(2);
}
const defaultKeys = path.join(
__dirname,
'..',
'compliance',
'cache',
'latest',
'test-vectors',
'request-signing',
'keys.json'
);
const keysJsonPath = keysPath ?? (existsSync(defaultKeys) ? defaultKeys : null);
if (!keysJsonPath) {
console.error(
'No --keys file provided and no default spec keys.json found. Run `npm run sync-schemas` first, or pass --keys <path>.'
);
process.exit(2);
}
const keys = JSON.parse(readFileSync(keysJsonPath, 'utf8')).keys;
const keysByKid = new Map(keys.map(k => [k.kid, k]));
const vector = JSON.parse(readFileSync(vectorPath, 'utf8'));
const now = vector.reference_now ?? Math.floor(Date.now() / 1000);

const replayStore = new InMemoryReplayStore();
const revocationStore = new InMemoryRevocationStore();
const state = vector.test_harness_state ?? {};
if (state.replay_cache_entries) {
for (const entry of state.replay_cache_entries) {
replayStore.preload(entry.keyid, entry.nonce, entry.ttl_seconds, now);
}
}
if (state.revocation_list) revocationStore.load(state.revocation_list);
if (state.replay_cache_per_keyid_cap_hit) {
replayStore.setCapHitForTesting(state.replay_cache_per_keyid_cap_hit.keyid);
}

const jwksEntries = vector.jwks_override
? vector.jwks_override.keys
: (vector.jwks_ref ?? []).map(kid => keysByKid.get(kid)).filter(Boolean);
const jwks = new StaticJwksResolver(jwksEntries);
const operation = new URL(vector.request.url).pathname.split('/').filter(Boolean).pop();

try {
const verified = await verifyRequestSignature(vector.request, {
capability: vector.verifier_capability,
jwks,
replayStore,
revocationStore,
now: () => now,
operation,
});
console.log(JSON.stringify({ outcome: 'accepted', verified_signer: verified, operation }, null, 2));
return { accepted: true };
} catch (err) {
if (err instanceof RequestSignatureError) {
const payload = {
outcome: 'rejected',
error_code: err.code,
failed_step: err.failedStep,
message: err.message,
};
console.log(JSON.stringify(payload, null, 2));
return { accepted: false, code: err.code };
}
throw err;
}
}

function printGenerateHelp() {
console.log(`Usage: adcp signing generate-key [options]

Generate an Ed25519 (default) or P-256 keypair for AdCP request signing.
Writes or prints a PEM-encoded PKCS#8 private key + a one-key JWKS you can
publish at your agent's jwks_uri.

Options:
--alg <ed25519|es256> Signature algorithm (default: ed25519).
--kid <value> JWK kid. Default: adcp-<alg>-<year>.
--private-out <path> Write private PEM to a file (mode 0600) instead of stdout.
--public-out <path> Write JWKS JSON to a file instead of stdout.
-h, --help Show this help.
`);
}

function printVerifyHelp() {
console.log(`Usage: adcp signing verify-vector --vector <path> [--keys <keys.json>]

Run a single RFC 9421 test vector through the AdCP verifier. Useful for
debugging conformance failures against the spec's positive/negative vectors
at compliance/cache/latest/test-vectors/request-signing/.

If --keys is omitted, defaults to the bundled spec keys.json.
`);
}

function printSigningHelp() {
console.log(`Usage: adcp signing <subcommand>

Subcommands:
generate-key Generate an Ed25519/P-256 keypair + JWKS for publication.
verify-vector Run one RFC 9421 test vector through the verifier.
`);
}

async function handleSigningCommand(argv) {
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
printSigningHelp();
process.exit(0);
}
const [sub, ...rest] = argv;
if (sub === 'generate-key') return generateKey(rest);
if (sub === 'verify-vector') {
const result = await verifyVector(rest);
process.exit(result.accepted ? 0 : 1);
}
console.error(`Unknown signing subcommand: ${sub}`);
printSigningHelp();
process.exit(2);
}

module.exports = { handleSigningCommand };
6 changes: 6 additions & 0 deletions bin/adcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,12 @@ async function main() {
return;
}

if (args[0] === 'signing') {
const { handleSigningCommand } = require('./adcp-signing.js');
await handleSigningCommand(args.slice(1));
return;
}

// Handle help
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
printUsage();
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"import": "./dist/lib/server/index.js",
"require": "./dist/lib/server/index.js",
"types": "./dist/lib/server/index.d.ts"
},
"./signing": {
"import": "./dist/lib/signing/index.js",
"require": "./dist/lib/signing/index.js",
"types": "./dist/lib/signing/index.d.ts"
}
},
"typesVersions": {
Expand All @@ -53,6 +58,9 @@
],
"server": [
"dist/lib/server/index.d.ts"
],
"signing": [
"dist/lib/signing/index.d.ts"
]
}
},
Expand Down
Loading
Loading