diff --git a/.changeset/request-signing-rfc-9421.md b/.changeset/request-signing-rfc-9421.md new file mode 100644 index 000000000..9e8b71906 --- /dev/null +++ b/.changeset/request-signing-rfc-9421.md @@ -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`. diff --git a/README.md b/README.md index 85a2cae22..5019157a4 100644 --- a/README.md +++ b/README.md @@ -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="" +// 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 diff --git a/bin/adcp-signing.js b/bin/adcp-signing.js new file mode 100644 index 000000000..296faa512 --- /dev/null +++ b/bin/adcp-signing.js @@ -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 .' + ); + 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 Signature algorithm (default: ed25519). + --kid JWK kid. Default: adcp--. + --private-out Write private PEM to a file (mode 0600) instead of stdout. + --public-out Write JWKS JSON to a file instead of stdout. + -h, --help Show this help. +`); +} + +function printVerifyHelp() { + console.log(`Usage: adcp signing verify-vector --vector [--keys ] + +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 + +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 }; diff --git a/bin/adcp.js b/bin/adcp.js index 7a011af9f..841045172 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -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(); diff --git a/package.json b/package.json index 9a5f006c4..a691fd02e 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -53,6 +58,9 @@ ], "server": [ "dist/lib/server/index.d.ts" + ], + "signing": [ + "dist/lib/signing/index.d.ts" ] } }, diff --git a/src/lib/signing/canonicalize.ts b/src/lib/signing/canonicalize.ts new file mode 100644 index 000000000..1d56f1b3a --- /dev/null +++ b/src/lib/signing/canonicalize.ts @@ -0,0 +1,137 @@ +import { RequestSignatureError } from './errors'; + +export interface RequestLike { + method: string; + url: string; + headers: Record; + body?: string; +} + +export interface SignatureParams { + created: number; + expires: number; + nonce: string; + keyid: string; + alg: string; + tag: string; +} + +const DEFAULT_PARAM_ORDER: ReadonlyArray = [ + 'created', + 'expires', + 'nonce', + 'keyid', + 'alg', + 'tag', +]; + +const STRING_PARAMS = new Set(['nonce', 'keyid', 'alg', 'tag']); + +const SUPPORTED_DERIVED = new Set(['@method', '@target-uri', '@authority']); + +export function canonicalTargetUri(rawUrl: string): string { + const u = new URL(rawUrl); + if (u.username || u.password) { + throw new RequestSignatureError( + 'request_signature_header_malformed', + 1, + '@target-uri must not include userinfo; strip credentials before signing' + ); + } + const assembled = `${u.protocol}//${u.host}${u.pathname}${u.search}`; + return uppercasePercentEncoding(assembled); +} + +export function canonicalAuthority(rawUrl: string): string { + const u = new URL(rawUrl); + return u.host.toLowerCase(); +} + +export function canonicalMethod(method: string): string { + return method.toUpperCase(); +} + +export function getHeaderValue( + headers: Record, + name: string +): string | undefined { + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) { + if (v === undefined) return undefined; + if (Array.isArray(v)) { + return v.map(entry => entry.trim()).join(', '); + } + return v.trim(); + } + } + return undefined; +} + +/** + * Build the RFC 9421 §2.5 signature base. + * + * When `signatureParamsValue` is supplied (verifier path), the function emits + * it verbatim as the value of the `@signature-params` line — this preserves + * byte-identity with the `Signature-Input` header a peer actually sent, even + * if their param order differs from ours. When omitted (signer path), the + * function formats from `params` using a fixed canonical order. + */ +export function buildSignatureBase( + components: ReadonlyArray, + request: RequestLike, + params: SignatureParams, + signatureParamsValue?: string +): string { + const lines: string[] = []; + for (const component of components) { + const value = resolveComponentValue(component, request); + if (value === undefined) { + throw new RequestSignatureError( + 'request_signature_components_incomplete', + 6, + `Covered component "${component}" not present in request` + ); + } + lines.push(`"${component}": ${value}`); + } + const paramsString = signatureParamsValue ?? formatSignatureParams(components, params); + lines.push(`"@signature-params": ${paramsString}`); + return lines.join('\n'); +} + +export function formatSignatureParams(components: ReadonlyArray, params: SignatureParams): string { + const componentList = components.map(c => `"${c}"`).join(' '); + const paramPairs: string[] = []; + for (const key of DEFAULT_PARAM_ORDER) { + const raw = params[key]; + if (raw === undefined) continue; + paramPairs.push(STRING_PARAMS.has(key) ? `${key}="${raw}"` : `${key}=${raw}`); + } + return `(${componentList});${paramPairs.join(';')}`; +} + +function resolveComponentValue(component: string, request: RequestLike): string | undefined { + if (component.startsWith('@')) { + if (!SUPPORTED_DERIVED.has(component)) { + throw new RequestSignatureError( + 'request_signature_components_unexpected', + 6, + `Derived component "${component}" is not supported by the AdCP request-signing profile` + ); + } + switch (component) { + case '@method': + return canonicalMethod(request.method); + case '@target-uri': + return canonicalTargetUri(request.url); + case '@authority': + return canonicalAuthority(request.url); + } + } + return getHeaderValue(request.headers, component); +} + +function uppercasePercentEncoding(input: string): string { + return input.replace(/%([0-9a-fA-F]{2})/g, (_m, hex: string) => `%${hex.toUpperCase()}`); +} diff --git a/src/lib/signing/content-digest.ts b/src/lib/signing/content-digest.ts new file mode 100644 index 000000000..56b3b082e --- /dev/null +++ b/src/lib/signing/content-digest.ts @@ -0,0 +1,35 @@ +import { createHash, timingSafeEqual } from 'crypto'; + +const SHA256_MEMBER_RE = /(^|[,\s])sha-256=:([A-Za-z0-9+/_-]+={0,2}):/; + +export function computeContentDigest(body: string | Uint8Array): string { + const buf = toBuffer(body); + const hash = createHash('sha256').update(buf).digest('base64'); + return `sha-256=:${hash}:`; +} + +/** + * Extract the `sha-256` member from an RFC 9530 Content-Digest header. The + * header is an RFC 8941 Dictionary and MAY list multiple algorithms + * (e.g. `sha-256=:...:, sha-512=:...:`); we look up the `sha-256` member + * without requiring any particular position. + */ +export function parseContentDigest(header: string): Buffer | null { + const m = header.trim().match(SHA256_MEMBER_RE); + if (!m || !m[2]) return null; + return Buffer.from(m[2], 'base64'); +} + +export function contentDigestMatches(header: string, body: string | Uint8Array): boolean { + const expected = parseContentDigest(header); + if (!expected) return false; + const buf = toBuffer(body); + const actual = createHash('sha256').update(buf).digest(); + if (expected.length !== actual.length) return false; + return timingSafeEqual(expected, actual); +} + +function toBuffer(body: string | Uint8Array): Buffer { + if (typeof body === 'string') return Buffer.from(body, 'utf8'); + return Buffer.from(body.buffer, body.byteOffset, body.byteLength); +} diff --git a/src/lib/signing/crypto.ts b/src/lib/signing/crypto.ts new file mode 100644 index 000000000..2e3b8c4df --- /dev/null +++ b/src/lib/signing/crypto.ts @@ -0,0 +1,36 @@ +import { createPublicKey, verify as nodeVerify, type JsonWebKey, type KeyObject } from 'crypto'; +import { RequestSignatureError } from './errors'; +import type { AdcpJsonWebKey } from './types'; + +export function jwkToPublicKey(jwk: AdcpJsonWebKey): KeyObject { + const publicJwk: Record = { ...jwk }; + delete publicJwk._private_d_for_test_only; + delete publicJwk.d; + try { + return createPublicKey({ key: publicJwk as JsonWebKey, format: 'jwk' }); + } catch (err) { + throw new RequestSignatureError( + 'request_signature_key_unknown', + 7, + `JWK for keyid "${(jwk.kid as string) ?? ''}" is malformed`, + err + ); + } +} + +export function verifySignature(alg: string, publicKey: KeyObject, data: Uint8Array, signature: Uint8Array): boolean { + try { + if (alg === 'ed25519') { + return nodeVerify(null, data, publicKey, signature); + } + if (alg === 'ecdsa-p256-sha256') { + return nodeVerify('sha256', data, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature); + } + return false; + } catch { + // Malformed signature bytes, mismatched key/alg, etc. — map to a caller- + // observable verify failure rather than letting an opaque crypto Error + // escape the 12-step pipeline. + return false; + } +} diff --git a/src/lib/signing/errors.ts b/src/lib/signing/errors.ts new file mode 100644 index 000000000..816382610 --- /dev/null +++ b/src/lib/signing/errors.ts @@ -0,0 +1,29 @@ +import { ADCPError } from '../errors'; + +export type RequestSignatureErrorCode = + | 'request_signature_required' + | 'request_signature_header_malformed' + | 'request_signature_params_incomplete' + | 'request_signature_tag_invalid' + | 'request_signature_alg_not_allowed' + | 'request_signature_window_invalid' + | 'request_signature_components_incomplete' + | 'request_signature_components_unexpected' + | 'request_signature_key_unknown' + | 'request_signature_key_purpose_invalid' + | 'request_signature_key_revoked' + | 'request_signature_invalid' + | 'request_signature_digest_mismatch' + | 'request_signature_replayed' + | 'request_signature_rate_abuse'; + +export class RequestSignatureError extends ADCPError { + readonly code: RequestSignatureErrorCode; + readonly failedStep: number; + + constructor(code: RequestSignatureErrorCode, failedStep: number, message: string, details?: unknown) { + super(message, details); + this.code = code; + this.failedStep = failedStep; + } +} diff --git a/src/lib/signing/fetch.ts b/src/lib/signing/fetch.ts new file mode 100644 index 000000000..d23476f38 --- /dev/null +++ b/src/lib/signing/fetch.ts @@ -0,0 +1,60 @@ +import { signRequest, type SignerKey, type SignRequestOptions } from './signer'; + +export interface SigningFetchOptions extends SignRequestOptions { + shouldSign?: (url: string, init: RequestInit | undefined) => boolean; +} + +type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +export function createSigningFetch(upstream: FetchLike, key: SignerKey, options: SigningFetchOptions = {}): FetchLike { + return async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + const shouldSign = options.shouldSign ?? (() => method !== 'GET'); + if (!shouldSign(url, init)) return upstream(input, init); + + if (input instanceof Request) { + throw new TypeError( + 'createSigningFetch does not accept Request objects (the body would be consumed out from under the signer). Pass a URL string and a separate init.' + ); + } + + const headers = headersToRecord(init?.headers); + const hasContentType = Object.keys(headers).some(k => k.toLowerCase() === 'content-type'); + if (!hasContentType && (init?.body !== undefined || method !== 'GET')) { + headers['content-type'] = 'application/json'; + } + const body = bodyToString(init?.body); + + const signed = signRequest({ method, url, headers, body }, key, options); + + const mergedInit: RequestInit = { ...init, method, headers: signed.headers }; + if (body !== undefined && mergedInit.body === undefined) mergedInit.body = body; + return upstream(url, mergedInit); + }; +} + +function headersToRecord(headers: HeadersInit | Headers | undefined): Record { + if (!headers) return {}; + if (headers instanceof Headers) { + const out: Record = {}; + headers.forEach((v, k) => { + out[k] = v; + }); + return out; + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return { ...(headers as Record) }; +} + +function bodyToString(body: RequestInit['body']): string | undefined { + if (body === undefined || body === null) return undefined; + if (typeof body === 'string') return body; + if (body instanceof Uint8Array) return Buffer.from(body).toString('utf8'); + if (body instanceof ArrayBuffer) return Buffer.from(body).toString('utf8'); + throw new TypeError( + 'createSigningFetch requires a string, Uint8Array, or ArrayBuffer body. FormData / Blob / ReadableStream are not supported because the signature must cover the exact wire bytes.' + ); +} diff --git a/src/lib/signing/index.ts b/src/lib/signing/index.ts new file mode 100644 index 000000000..510ea2fd8 --- /dev/null +++ b/src/lib/signing/index.ts @@ -0,0 +1,38 @@ +export { + buildSignatureBase, + canonicalAuthority, + canonicalMethod, + canonicalTargetUri, + formatSignatureParams, + getHeaderValue, + type RequestLike, + type SignatureParams, +} from './canonicalize'; +export { computeContentDigest, contentDigestMatches, parseContentDigest } from './content-digest'; +export { jwkToPublicKey, verifySignature } from './crypto'; +export { RequestSignatureError, type RequestSignatureErrorCode } from './errors'; +export { StaticJwksResolver, type JwksResolver } from './jwks'; +export { parseSignature, parseSignatureInput, type ParsedSignature, type ParsedSignatureInput } from './parser'; +export { + InMemoryReplayStore, + type InMemoryReplayStoreOptions, + type ReplayInsertResult, + type ReplayStore, +} from './replay'; +export { InMemoryRevocationStore, type RevocationStore } from './revocation'; +export { + ALLOWED_ALGS, + CLOCK_SKEW_TOLERANCE_SECONDS, + MANDATORY_COMPONENTS, + MAX_SIGNATURE_WINDOW_SECONDS, + REQUEST_SIGNING_TAG, + type AdcpJsonWebKey, + type ContentDigestPolicy, + type RevocationSnapshot, + type VerifiedSigner, + type VerifierCapability, +} from './types'; +export { verifyRequestSignature, type VerifyRequestOptions } from './verifier'; +export { signRequest, type SignedRequest, type SignerKey, type SignRequestOptions } from './signer'; +export { createSigningFetch, type SigningFetchOptions } from './fetch'; +export { createExpressVerifier, type ExpressLike, type ExpressMiddlewareOptions } from './middleware'; diff --git a/src/lib/signing/jwks.ts b/src/lib/signing/jwks.ts new file mode 100644 index 000000000..ba00eea0b --- /dev/null +++ b/src/lib/signing/jwks.ts @@ -0,0 +1,17 @@ +import type { AdcpJsonWebKey } from './types'; + +export interface JwksResolver { + resolve(keyid: string): Promise; +} + +export class StaticJwksResolver implements JwksResolver { + private readonly byKid = new Map(); + + constructor(keys: AdcpJsonWebKey[]) { + for (const k of keys) this.byKid.set(k.kid, k); + } + + async resolve(keyid: string): Promise { + return this.byKid.get(keyid) ?? null; + } +} diff --git a/src/lib/signing/middleware.ts b/src/lib/signing/middleware.ts new file mode 100644 index 000000000..bb79a346b --- /dev/null +++ b/src/lib/signing/middleware.ts @@ -0,0 +1,99 @@ +import type { RequestLike } from './canonicalize'; +import { getHeaderValue } from './canonicalize'; +import { RequestSignatureError } from './errors'; +import { verifyRequestSignature, type VerifyRequestOptions } from './verifier'; +import type { VerifiedSigner } from './types'; + +declare module 'http' { + interface IncomingMessage { + verifiedSigner?: VerifiedSigner; + rawBody?: string; + } +} + +export interface ExpressLike { + method: string; + originalUrl?: string; + url: string; + headers: Record; + rawBody?: string; + body?: unknown; + protocol?: string; + get?(header: string): string | undefined; + [key: string]: unknown; +} + +export interface ExpressMiddlewareOptions extends Omit { + resolveOperation: (req: ExpressLike) => string; + /** + * Override how the request's full URL is reconstructed. Use when the server + * sits behind a TLS-terminating or path-rewriting load balancer, since + * `req.protocol`/`req.get('host')` may not reflect what the signer signed. + * Must return the exact URL the signer used (scheme + authority + path + query). + */ + getUrl?: (req: ExpressLike) => string; +} + +type NextFn = (err?: unknown) => void; + +export function createExpressVerifier(options: ExpressMiddlewareOptions) { + return async function requestSignatureMiddleware( + req: ExpressLike, + res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } }, + next: NextFn + ): Promise { + try { + const url = options.getUrl ? options.getUrl(req) : defaultUrl(req); + const body = resolveRawBody(req); + const requestLike: RequestLike = { + method: req.method, + url, + headers: req.headers, + body, + }; + const verified = await verifyRequestSignature(requestLike, { + ...options, + operation: options.resolveOperation(req), + }); + req.verifiedSigner = verified; + next(); + } catch (err) { + if (err instanceof RequestSignatureError) { + // `failed_step` is informational per spec; keep it in server-side logs + // rather than the 401 body so anonymous callers can't enumerate the + // verifier pipeline. + res.status(401).set('WWW-Authenticate', `Signature error="${err.code}"`).json({ + error: err.code, + message: err.message, + }); + return; + } + next(err); + } + }; +} + +function resolveRawBody(req: ExpressLike): string { + if (typeof req.rawBody === 'string') return req.rawBody; + const contentLengthHeader = getHeaderValue(req.headers, 'content-length'); + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : 0; + if (Number.isFinite(contentLength) && contentLength > 0) { + throw new RequestSignatureError( + 'request_signature_header_malformed', + 1, + 'req.rawBody is required for signed requests with a body; install a raw-body capture middleware ahead of createExpressVerifier' + ); + } + return ''; +} + +function defaultUrl(req: ExpressLike): string { + const proto = req.protocol ?? (typeof req.get === 'function' ? req.get('x-forwarded-proto') : undefined) ?? 'https'; + const host = typeof req.get === 'function' ? req.get('host') : undefined; + if (!host) { + throw new Error( + 'Unable to derive request URL: missing Host header. Pass `getUrl` on createExpressVerifier to supply the canonical URL explicitly.' + ); + } + return `${proto}://${host}${req.originalUrl ?? req.url}`; +} diff --git a/src/lib/signing/parser.ts b/src/lib/signing/parser.ts new file mode 100644 index 000000000..8ae99eff6 --- /dev/null +++ b/src/lib/signing/parser.ts @@ -0,0 +1,235 @@ +import { RequestSignatureError } from './errors'; + +export interface ParsedSignatureInput { + label: string; + components: string[]; + /** + * The `Signature-Input` value verbatim, minus the `