Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/lib/core/ADCPMultiAgentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,8 @@ export class ADCPMultiAgentClient {
taskType: string,
operationId: string,
signature?: string,
timestamp?: string | number
timestamp?: string | number,
rawBody?: string
): Promise<boolean> {
// Extract agent ID from payload
// Webhook payloads include agent_id or we can infer from operation_id pattern
Expand All @@ -944,7 +945,7 @@ export class ADCPMultiAgentClient {
}

const agent = this.getAgent(agentId);
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp);
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
}

/**
Expand Down
17 changes: 11 additions & 6 deletions src/lib/core/AgentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ export class AgentClient {
taskType: string,
operationId: string,
signature?: string,
timestamp?: string | number
timestamp?: string | number,
rawBody?: string
): Promise<boolean> {
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp);
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
}

/**
Expand All @@ -104,15 +105,19 @@ export class AgentClient {
}

/**
* Verify webhook signature using HMAC-SHA256 per AdCP PR #86 spec
* Verify webhook signature using HMAC-SHA256 per AdCP spec.
*
* @param payload - Webhook payload object
* Prefer passing the raw HTTP body string for correct cross-language interop.
* Passing a parsed object still works but re-serializes with JSON.stringify,
* which may not match the sender's byte representation.
*
* @param rawBodyOrPayload - Raw HTTP body string (preferred) or parsed payload object (deprecated)
* @param signature - X-ADCP-Signature header value (format: "sha256=...")
* @param timestamp - X-ADCP-Timestamp header value (Unix timestamp)
* @returns true if signature is valid
*/
verifyWebhookSignature(payload: any, signature: string, timestamp: string | number): boolean {
return this.client.verifyWebhookSignature(payload, signature, timestamp);
verifyWebhookSignature(rawBodyOrPayload: string | any, signature: string, timestamp: string | number): boolean {
return this.client.verifyWebhookSignature(rawBodyOrPayload, signature, timestamp);
}

// ====== MEDIA BUY TASKS ======
Expand Down
40 changes: 29 additions & 11 deletions src/lib/core/SingleAgentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,15 +566,16 @@ export class SingleAgentClient {
taskType: string,
operationId: string,
signature?: string,
timestamp?: string | number
timestamp?: string | number,
rawBody?: string
): Promise<boolean> {
// Verify signature if secret is configured
if (this.config.webhookSecret) {
if (!signature || !timestamp) {
throw new Error('Webhook signature and timestamp required but not provided');
}

const isValid = this.verifyWebhookSignature(payload, signature, timestamp);
const isValid = this.verifyWebhookSignature(rawBody ?? payload, signature, timestamp);
if (!isValid) {
throw new Error('Invalid webhook signature or timestamp too old');
}
Expand Down Expand Up @@ -783,11 +784,16 @@ export class SingleAgentClient {
const signature = req.headers['x-adcp-signature'] || req.headers['X-ADCP-Signature'];
const timestamp = req.headers['x-adcp-timestamp'] || req.headers['X-ADCP-Timestamp'];

// Parse body if needed
// Capture raw body for signature verification, then parse
const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
const payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;

// Handle webhook with automatic verification
const handled = await this.handleWebhook(payload, signature, timestamp);
// Extract routing params if available (e.g., Express route params)
const taskType = req.params?.task_type || req.params?.taskType || 'unknown';
const operationId = req.params?.operation_id || req.params?.operationId || 'unknown';

// Handle webhook with automatic verification using raw body bytes
const handled = await this.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);

// Return success
if (res.json) {
Expand All @@ -811,17 +817,26 @@ export class SingleAgentClient {
}

/**
* Verify webhook signature using HMAC-SHA256 per AdCP PR #86 spec
* Verify webhook signature using HMAC-SHA256 per AdCP spec.
*
* HMAC is computed over the **raw HTTP body bytes** — the exact bytes received
* on the wire, before JSON parsing. This ensures cross-language interop since
* different JSON serializers may produce different byte representations of the
* same logical payload.
*
* For backward compatibility, a parsed object is still accepted but will be
* re-serialized with JSON.stringify, which may not match the sender's bytes.
* Always prefer passing the raw body string.
*
* Signature format: sha256={hex_signature}
* Message format: {timestamp}.{json_payload}
* Message format: {timestamp}.{raw_body}
*
* @param payload - Webhook payload object
* @param rawBodyOrPayload - Raw HTTP body string (preferred) or parsed payload object (deprecated)
* @param signature - X-ADCP-Signature header value (format: "sha256=...")
* @param timestamp - X-ADCP-Timestamp header value (Unix timestamp)
* @returns true if signature is valid
*/
verifyWebhookSignature(payload: any, signature: string, timestamp: string | number): boolean {
verifyWebhookSignature(rawBodyOrPayload: string | any, signature: string, timestamp: string | number): boolean {
if (!this.config.webhookSecret) {
return false;
}
Expand All @@ -834,8 +849,11 @@ export class SingleAgentClient {
return false; // Request too old or from future
}

// Build message per AdCP spec: {timestamp}.{json_payload}
const message = `${ts}.${JSON.stringify(payload)}`;
// Use raw body bytes when available; fall back to JSON.stringify for backward compat
const body = typeof rawBodyOrPayload === 'string' ? rawBodyOrPayload : JSON.stringify(rawBodyOrPayload);

// Build message per AdCP spec: {timestamp}.{raw_body}
const message = `${ts}.${body}`;

// Calculate expected signature
const hmac = crypto.createHmac('sha256', this.config.webhookSecret);
Expand Down
23 changes: 22 additions & 1 deletion src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Fastify, { FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyCors from '@fastify/cors';
import path from 'path';
import { Readable } from 'stream';
import {
ADCPMultiAgentClient,
ConfigurationManager,
Expand Down Expand Up @@ -61,6 +62,23 @@ const app: FastifyInstance = Fastify({
},
});

// Capture raw body on webhook routes for HMAC signature verification.
// The raw bytes must be used (not re-serialized JSON) for cross-language interop.
app.addHook('preParsing', async (request, _reply, payload) => {
if (request.url.startsWith('/webhook/')) {
const chunks: Buffer[] = [];
for await (const chunk of payload) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const rawBody = Buffer.concat(chunks).toString('utf8');
(request as any).rawBody = rawBody;

// Return a new readable stream so Fastify can still parse the body
return Readable.from([rawBody]);
}
return payload;
});

// Initialize ADCP client with configured agents
const configuredAgents = ConfigurationManager.loadAgentsFromEnv();

Expand Down Expand Up @@ -2033,6 +2051,9 @@ app.post<{
const signature = request.headers['x-adcp-signature'] as string | undefined;
const timestamp = request.headers['x-adcp-timestamp'] as string | undefined;

// Use raw body for HMAC verification when available, fall back to re-serialized payload
const rawBody = (request as any).rawBody as string | undefined;

app.log.info(`Webhook received: ${taskType} for operation ${operationId}`);

// TODO: Validate payload against JSON schema for task_type
Expand All @@ -2052,7 +2073,7 @@ app.post<{
// The webhook URL was generated with this agent_id during operation setup
// We use it to look up the correct agent configuration (auth, protocol, etc)
const agent = adcpClient.agent(agentId);
const handled = await agent.handleWebhook(payload, taskType, operationId, signature, timestamp);
const handled = await agent.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);

if (!handled) {
app.log.warn(`Webhook not handled - no handlers configured`);
Expand Down
93 changes: 61 additions & 32 deletions test/webhook-signature-verification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,52 @@ describe('Webhook Signature Verification (PR #86 Spec)', () => {

const webhookSecret = 'test-secret-key-minimum-32-characters-long';

test('should verify valid webhook signature per PR #86 spec', () => {
test('should verify valid webhook signature using raw body string', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

const rawBody =
'{"event":"creative.status_changed","creative_id":"creative_123","status":"approved","timestamp":"2025-10-08T22:30:00Z"}';

const timestamp = Math.floor(Date.now() / 1000);

// Generate signature per spec: sha256=HMAC({timestamp}.{raw_body})
const message = `${timestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Verify signature using raw body string
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
assert.strictEqual(isValid, true);
});

test('should verify signature when raw body has different formatting than JSON.stringify', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

// Raw body with extra spaces (as a Python sender might produce)
const rawBody = '{"key": "value", "num": 1.0}';

const timestamp = Math.floor(Date.now() / 1000);

// Sender signs over raw body bytes
const message = `${timestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Verification with raw body should succeed
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
assert.strictEqual(isValid, true);

// Verification with parsed object would fail (different bytes)
const parsed = JSON.parse(rawBody);
const isValidParsed = agentClient.verifyWebhookSignature(parsed, signature, timestamp);
assert.strictEqual(isValidParsed, false, 'Parsed object re-serialization should not match raw body signature');
});

test('should still work with parsed object for backward compatibility', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

Expand All @@ -26,13 +71,13 @@ describe('Webhook Signature Verification (PR #86 Spec)', () => {

const timestamp = Math.floor(Date.now() / 1000);

// Generate signature per PR #86 spec: sha256=HMAC({timestamp}.{json_payload})
// Generate signature using JSON.stringify (old-style sender)
const message = `${timestamp}.${JSON.stringify(payload)}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Verify signature
// Verify signature using parsed object (backward compat)
const isValid = agentClient.verifyWebhookSignature(payload, signature, timestamp);
assert.strictEqual(isValid, true);
});
Expand All @@ -41,100 +86,84 @@ describe('Webhook Signature Verification (PR #86 Spec)', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

const payload = {
event: 'creative.status_changed',
creative_id: 'creative_123',
status: 'approved',
};
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';

const timestamp = Math.floor(Date.now() / 1000);
const invalidSignature = 'sha256=invalid_signature_here';

const isValid = agentClient.verifyWebhookSignature(payload, invalidSignature, timestamp);
const isValid = agentClient.verifyWebhookSignature(rawBody, invalidSignature, timestamp);
assert.strictEqual(isValid, false);
});

test('should reject webhook with old timestamp (> 5 minutes)', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

const payload = {
event: 'creative.status_changed',
creative_id: 'creative_123',
status: 'approved',
};
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';

// Timestamp from 10 minutes ago
const oldTimestamp = Math.floor(Date.now() / 1000) - 600;

// Generate valid signature for old timestamp
const message = `${oldTimestamp}.${JSON.stringify(payload)}`;
const message = `${oldTimestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Should reject due to timestamp being too old
const isValid = agentClient.verifyWebhookSignature(payload, signature, oldTimestamp);
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, oldTimestamp);
assert.strictEqual(isValid, false);
});

test('should accept webhook within 5 minute window', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

const payload = {
event: 'creative.status_changed',
creative_id: 'creative_123',
status: 'approved',
};
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';

// Timestamp from 2 minutes ago (within 5 minute window)
const recentTimestamp = Math.floor(Date.now() / 1000) - 120;

// Generate valid signature
const message = `${recentTimestamp}.${JSON.stringify(payload)}`;
const message = `${recentTimestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Should accept
const isValid = agentClient.verifyWebhookSignature(payload, signature, recentTimestamp);
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, recentTimestamp);
assert.strictEqual(isValid, true);
});

test('should handle timestamp as string', () => {
const client = new AdCPClient([agent], { webhookSecret });
const agentClient = client.agent('test_agent');

const payload = {
event: 'creative.status_changed',
creative_id: 'creative_123',
status: 'approved',
};
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';

const timestamp = Math.floor(Date.now() / 1000);
const timestampStr = timestamp.toString();

// Generate valid signature
const message = `${timestamp}.${JSON.stringify(payload)}`;
const message = `${timestamp}.${rawBody}`;
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(message);
const signature = `sha256=${hmac.digest('hex')}`;

// Should accept string timestamp
const isValid = agentClient.verifyWebhookSignature(payload, signature, timestampStr);
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestampStr);
assert.strictEqual(isValid, true);
});

test('should return false when webhookSecret not configured', () => {
const client = new AdCPClient([agent], {}); // No webhookSecret
const agentClient = client.agent('test_agent');

const payload = { event: 'test' };
const rawBody = '{"event":"test"}';
const timestamp = Math.floor(Date.now() / 1000);
const signature = 'sha256=anything';

const isValid = agentClient.verifyWebhookSignature(payload, signature, timestamp);
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
assert.strictEqual(isValid, false);
});
});