Skip to content

Commit b698a4f

Browse files
fix: use raw body bytes for webhook HMAC verification (#313)
verifyWebhookSignature was re-serializing parsed JSON via JSON.stringify, which produces different bytes than the sender's serializer (spacing, key order, number formatting). This broke cross-language HMAC interop. Now accepts raw HTTP body string for correct verification. Parsed objects still work for backward compatibility but fall back to JSON.stringify.
1 parent 1bb8633 commit b698a4f

5 files changed

Lines changed: 127 additions & 52 deletions

File tree

src/lib/core/ADCPMultiAgentClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,8 @@ export class ADCPMultiAgentClient {
933933
taskType: string,
934934
operationId: string,
935935
signature?: string,
936-
timestamp?: string | number
936+
timestamp?: string | number,
937+
rawBody?: string
937938
): Promise<boolean> {
938939
// Extract agent ID from payload
939940
// Webhook payloads include agent_id or we can infer from operation_id pattern
@@ -944,7 +945,7 @@ export class ADCPMultiAgentClient {
944945
}
945946

946947
const agent = this.getAgent(agentId);
947-
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp);
948+
return agent.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
948949
}
949950

950951
/**

src/lib/core/AgentClient.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ export class AgentClient {
8787
taskType: string,
8888
operationId: string,
8989
signature?: string,
90-
timestamp?: string | number
90+
timestamp?: string | number,
91+
rawBody?: string
9192
): Promise<boolean> {
92-
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp);
93+
return this.client.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
9394
}
9495

9596
/**
@@ -104,15 +105,19 @@ export class AgentClient {
104105
}
105106

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

118123
// ====== MEDIA BUY TASKS ======

src/lib/core/SingleAgentClient.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -566,15 +566,16 @@ export class SingleAgentClient {
566566
taskType: string,
567567
operationId: string,
568568
signature?: string,
569-
timestamp?: string | number
569+
timestamp?: string | number,
570+
rawBody?: string
570571
): Promise<boolean> {
571572
// Verify signature if secret is configured
572573
if (this.config.webhookSecret) {
573574
if (!signature || !timestamp) {
574575
throw new Error('Webhook signature and timestamp required but not provided');
575576
}
576577

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

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

789-
// Handle webhook with automatic verification
790-
const handled = await this.handleWebhook(payload, signature, timestamp);
791+
// Extract routing params if available (e.g., Express route params)
792+
const taskType = req.params?.task_type || req.params?.taskType || 'unknown';
793+
const operationId = req.params?.operation_id || req.params?.operationId || 'unknown';
794+
795+
// Handle webhook with automatic verification using raw body bytes
796+
const handled = await this.handleWebhook(payload, taskType, operationId, signature, timestamp, rawBody);
791797

792798
// Return success
793799
if (res.json) {
@@ -811,17 +817,26 @@ export class SingleAgentClient {
811817
}
812818

813819
/**
814-
* Verify webhook signature using HMAC-SHA256 per AdCP PR #86 spec
820+
* Verify webhook signature using HMAC-SHA256 per AdCP spec.
821+
*
822+
* HMAC is computed over the **raw HTTP body bytes** — the exact bytes received
823+
* on the wire, before JSON parsing. This ensures cross-language interop since
824+
* different JSON serializers may produce different byte representations of the
825+
* same logical payload.
826+
*
827+
* For backward compatibility, a parsed object is still accepted but will be
828+
* re-serialized with JSON.stringify, which may not match the sender's bytes.
829+
* Always prefer passing the raw body string.
815830
*
816831
* Signature format: sha256={hex_signature}
817-
* Message format: {timestamp}.{json_payload}
832+
* Message format: {timestamp}.{raw_body}
818833
*
819-
* @param payload - Webhook payload object
834+
* @param rawBodyOrPayload - Raw HTTP body string (preferred) or parsed payload object (deprecated)
820835
* @param signature - X-ADCP-Signature header value (format: "sha256=...")
821836
* @param timestamp - X-ADCP-Timestamp header value (Unix timestamp)
822837
* @returns true if signature is valid
823838
*/
824-
verifyWebhookSignature(payload: any, signature: string, timestamp: string | number): boolean {
839+
verifyWebhookSignature(rawBodyOrPayload: string | any, signature: string, timestamp: string | number): boolean {
825840
if (!this.config.webhookSecret) {
826841
return false;
827842
}
@@ -834,8 +849,13 @@ export class SingleAgentClient {
834849
return false; // Request too old or from future
835850
}
836851

837-
// Build message per AdCP spec: {timestamp}.{json_payload}
838-
const message = `${ts}.${JSON.stringify(payload)}`;
852+
// Use raw body bytes when available; fall back to JSON.stringify for backward compat
853+
const body = typeof rawBodyOrPayload === 'string'
854+
? rawBodyOrPayload
855+
: JSON.stringify(rawBodyOrPayload);
856+
857+
// Build message per AdCP spec: {timestamp}.{raw_body}
858+
const message = `${ts}.${body}`;
839859

840860
// Calculate expected signature
841861
const hmac = crypto.createHmac('sha256', this.config.webhookSecret);

src/server/server.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Fastify, { FastifyInstance } from 'fastify';
33
import fastifyStatic from '@fastify/static';
44
import fastifyCors from '@fastify/cors';
55
import path from 'path';
6+
import { Readable } from 'stream';
67
import {
78
ADCPMultiAgentClient,
89
ConfigurationManager,
@@ -61,6 +62,23 @@ const app: FastifyInstance = Fastify({
6162
},
6263
});
6364

65+
// Capture raw body on webhook routes for HMAC signature verification.
66+
// The raw bytes must be used (not re-serialized JSON) for cross-language interop.
67+
app.addHook('preParsing', async (request, _reply, payload) => {
68+
if (request.url.startsWith('/webhook/')) {
69+
const chunks: Buffer[] = [];
70+
for await (const chunk of payload) {
71+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
72+
}
73+
const rawBody = Buffer.concat(chunks).toString('utf8');
74+
(request as any).rawBody = rawBody;
75+
76+
// Return a new readable stream so Fastify can still parse the body
77+
return Readable.from([rawBody]);
78+
}
79+
return payload;
80+
});
81+
6482
// Initialize ADCP client with configured agents
6583
const configuredAgents = ConfigurationManager.loadAgentsFromEnv();
6684

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

2054+
// Use raw body for HMAC verification when available, fall back to re-serialized payload
2055+
const rawBody = (request as any).rawBody as string | undefined;
2056+
20362057
app.log.info(`Webhook received: ${taskType} for operation ${operationId}`);
20372058

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

20572078
if (!handled) {
20582079
app.log.warn(`Webhook not handled - no handlers configured`);

test/webhook-signature-verification.test.js

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,51 @@ describe('Webhook Signature Verification (PR #86 Spec)', () => {
1313

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

16-
test('should verify valid webhook signature per PR #86 spec', () => {
16+
test('should verify valid webhook signature using raw body string', () => {
17+
const client = new AdCPClient([agent], { webhookSecret });
18+
const agentClient = client.agent('test_agent');
19+
20+
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved","timestamp":"2025-10-08T22:30:00Z"}';
21+
22+
const timestamp = Math.floor(Date.now() / 1000);
23+
24+
// Generate signature per spec: sha256=HMAC({timestamp}.{raw_body})
25+
const message = `${timestamp}.${rawBody}`;
26+
const hmac = crypto.createHmac('sha256', webhookSecret);
27+
hmac.update(message);
28+
const signature = `sha256=${hmac.digest('hex')}`;
29+
30+
// Verify signature using raw body string
31+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
32+
assert.strictEqual(isValid, true);
33+
});
34+
35+
test('should verify signature when raw body has different formatting than JSON.stringify', () => {
36+
const client = new AdCPClient([agent], { webhookSecret });
37+
const agentClient = client.agent('test_agent');
38+
39+
// Raw body with extra spaces (as a Python sender might produce)
40+
const rawBody = '{"key": "value", "num": 1.0}';
41+
42+
const timestamp = Math.floor(Date.now() / 1000);
43+
44+
// Sender signs over raw body bytes
45+
const message = `${timestamp}.${rawBody}`;
46+
const hmac = crypto.createHmac('sha256', webhookSecret);
47+
hmac.update(message);
48+
const signature = `sha256=${hmac.digest('hex')}`;
49+
50+
// Verification with raw body should succeed
51+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
52+
assert.strictEqual(isValid, true);
53+
54+
// Verification with parsed object would fail (different bytes)
55+
const parsed = JSON.parse(rawBody);
56+
const isValidParsed = agentClient.verifyWebhookSignature(parsed, signature, timestamp);
57+
assert.strictEqual(isValidParsed, false, 'Parsed object re-serialization should not match raw body signature');
58+
});
59+
60+
test('should still work with parsed object for backward compatibility', () => {
1761
const client = new AdCPClient([agent], { webhookSecret });
1862
const agentClient = client.agent('test_agent');
1963

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

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

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

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

44-
const payload = {
45-
event: 'creative.status_changed',
46-
creative_id: 'creative_123',
47-
status: 'approved',
48-
};
88+
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';
4989

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

53-
const isValid = agentClient.verifyWebhookSignature(payload, invalidSignature, timestamp);
93+
const isValid = agentClient.verifyWebhookSignature(rawBody, invalidSignature, timestamp);
5494
assert.strictEqual(isValid, false);
5595
});
5696

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

61-
const payload = {
62-
event: 'creative.status_changed',
63-
creative_id: 'creative_123',
64-
status: 'approved',
65-
};
101+
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';
66102

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

70106
// Generate valid signature for old timestamp
71-
const message = `${oldTimestamp}.${JSON.stringify(payload)}`;
107+
const message = `${oldTimestamp}.${rawBody}`;
72108
const hmac = crypto.createHmac('sha256', webhookSecret);
73109
hmac.update(message);
74110
const signature = `sha256=${hmac.digest('hex')}`;
75111

76112
// Should reject due to timestamp being too old
77-
const isValid = agentClient.verifyWebhookSignature(payload, signature, oldTimestamp);
113+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, oldTimestamp);
78114
assert.strictEqual(isValid, false);
79115
});
80116

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

85-
const payload = {
86-
event: 'creative.status_changed',
87-
creative_id: 'creative_123',
88-
status: 'approved',
89-
};
121+
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';
90122

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

94126
// Generate valid signature
95-
const message = `${recentTimestamp}.${JSON.stringify(payload)}`;
127+
const message = `${recentTimestamp}.${rawBody}`;
96128
const hmac = crypto.createHmac('sha256', webhookSecret);
97129
hmac.update(message);
98130
const signature = `sha256=${hmac.digest('hex')}`;
99131

100132
// Should accept
101-
const isValid = agentClient.verifyWebhookSignature(payload, signature, recentTimestamp);
133+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, recentTimestamp);
102134
assert.strictEqual(isValid, true);
103135
});
104136

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

109-
const payload = {
110-
event: 'creative.status_changed',
111-
creative_id: 'creative_123',
112-
status: 'approved',
113-
};
141+
const rawBody = '{"event":"creative.status_changed","creative_id":"creative_123","status":"approved"}';
114142

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

118146
// Generate valid signature
119-
const message = `${timestamp}.${JSON.stringify(payload)}`;
147+
const message = `${timestamp}.${rawBody}`;
120148
const hmac = crypto.createHmac('sha256', webhookSecret);
121149
hmac.update(message);
122150
const signature = `sha256=${hmac.digest('hex')}`;
123151

124152
// Should accept string timestamp
125-
const isValid = agentClient.verifyWebhookSignature(payload, signature, timestampStr);
153+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestampStr);
126154
assert.strictEqual(isValid, true);
127155
});
128156

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

133-
const payload = { event: 'test' };
161+
const rawBody = '{"event":"test"}';
134162
const timestamp = Math.floor(Date.now() / 1000);
135163
const signature = 'sha256=anything';
136164

137-
const isValid = agentClient.verifyWebhookSignature(payload, signature, timestamp);
165+
const isValid = agentClient.verifyWebhookSignature(rawBody, signature, timestamp);
138166
assert.strictEqual(isValid, false);
139167
});
140168
});

0 commit comments

Comments
 (0)