From b8da7278f3f964068d8bc88782065bc5e6ac5b8c Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Thu, 27 Aug 2026 10:44:25 +0100 Subject: [PATCH] feat: rotate webhook signing keys without downtime Accept current and previous secrets only inside the grace window, compare HMACs in constant time, persist nonces to reject replays, and keep failure responses from identifying which key matched. --- docs/error-codes.md | 2 + docs/error-codes.yaml | 8 + docs/openapi.json | 2 + docs/webhooks.md | 25 +- src/errors/codes.ts | 6 + src/errors/errorCatalog.ts | 2 + src/openapi.yaml | 47 +++- src/routes/webhooks.openapi.test.ts | 3 +- src/routes/webhooks.ts | 3 +- src/webhooks/webhook.deliver.test.ts | 196 ++++++++++++++++ src/webhooks/webhook.nonceStore.test.ts | 41 ++++ src/webhooks/webhook.nonceStore.ts | 64 ++++++ src/webhooks/webhook.routes.ts | 10 +- src/webhooks/webhook.signature.test.ts | 288 +++++++++++++++++++----- src/webhooks/webhook.signature.ts | 144 ++++++++++-- src/webhooks/webhook.store.test.ts | 81 +++++++ src/webhooks/webhook.store.ts | 25 ++ 17 files changed, 847 insertions(+), 100 deletions(-) create mode 100644 src/webhooks/webhook.deliver.test.ts create mode 100644 src/webhooks/webhook.nonceStore.test.ts create mode 100644 src/webhooks/webhook.nonceStore.ts create mode 100644 src/webhooks/webhook.store.test.ts diff --git a/docs/error-codes.md b/docs/error-codes.md index cea1a366..0f27a942 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -72,6 +72,8 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes | `WEBHOOK_TIMESTAMP_OUT_OF_WINDOW` | Webhooks | | `MALFORMED_WEBHOOK_SIGNATURE` | Webhooks | | `INVALID_WEBHOOK_SIGNATURE` | Webhooks | +| `MALFORMED_WEBHOOK_NONCE` | Webhooks | +| `WEBHOOK_NONCE_REPLAYED` | Webhooks | | `INVALID_DELIVERY_ID` | Webhooks | | `INVALID_RETRY_POLICY` | Webhooks | | `DLQ_ENTRY_NOT_FOUND` | Webhooks | diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml index 31983b94..42a3d54b 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -259,6 +259,14 @@ error_codes: section: Webhooks description: Webhook signature verification failed + - code: MALFORMED_WEBHOOK_NONCE + section: Webhooks + description: Webhook nonce header is malformed + + - code: WEBHOOK_NONCE_REPLAYED + section: Webhooks + description: Webhook nonce has already been used + - code: INVALID_DELIVERY_ID section: Webhooks description: The delivery ID provided for webhook replay is missing or invalid diff --git a/docs/openapi.json b/docs/openapi.json index 484bd4c7..3b342210 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5815,6 +5815,8 @@ "WEBHOOK_TIMESTAMP_OUT_OF_WINDOW", "MALFORMED_WEBHOOK_SIGNATURE", "INVALID_WEBHOOK_SIGNATURE", + "MALFORMED_WEBHOOK_NONCE", + "WEBHOOK_NONCE_REPLAYED", "INVALID_DELIVERY_ID", "INVALID_RETRY_POLICY", "DLQ_ENTRY_NOT_FOUND", diff --git a/docs/webhooks.md b/docs/webhooks.md index 60a0c6c2..3d58ab3e 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -138,8 +138,9 @@ If you provide a `secret` during registration, each webhook delivery includes th | Header | Format | Description | |-----------------------------|---------------------|---------------------------------------| | `X-Request-Id` | string | Correlation ID from the triggering request | -| `X-Callora-Signature-256` | `sha256=` | HMAC-SHA256 of signed payload | -| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for replay defense | +| `X-Callora-Signature-256` | `sha256=` | HMAC-SHA256 of `..` | +| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for skew/replay defense | +| `X-Callora-Nonce` | 16–128 URL-safe chars | Unique request nonce; persisted and rejected on reuse | | `X-Callora-Event` | string | Event type being delivered | | `X-Callora-Delivery` | UUID | Unique delivery identifier for idempotency | | `User-Agent` | `Callora-Webhook/1.0` | Identifies Callora as the sender | @@ -147,25 +148,27 @@ If you provide a `secret` during registration, each webhook delivery includes th #### Signed Payload Format -The signed payload combines the timestamp and raw request body: +The signed payload combines the timestamp, nonce, and raw request body: ``` -. +.. ``` -For example, if the timestamp is `2026-05-31T10:00:00.000Z` and body is `{"event":"new_api_call"}`: +For example, if the timestamp is `2026-05-31T10:00:00.000Z`, the nonce is +`nonce-7c9e6679-7425-40de`, and the body is `{"event":"new_api_call"}`: ``` -2026-05-31T10:00:00.000Z.{"event":"new_api_call"} +2026-05-31T10:00:00.000Z.nonce-7c9e6679-7425-40de.{"event":"new_api_call"} ``` #### Verification Steps -1. **Extract headers** — Get `X-Callora-Signature-256` and `X-Callora-Timestamp` -2. **Reconstruct payload** — Combine `.` -3. **Compute expected signature** — HMAC-SHA256 with your secret -4. **Timing-safe comparison** — Compare using constant-time method -5. **Check timestamp** — Reject if outside 5-minute tolerance window (replay protection) +1. **Extract headers** — Get `X-Callora-Signature-256`, `X-Callora-Timestamp`, and `X-Callora-Nonce` +2. **Reconstruct payload** — Combine `..` +3. **Compute expected signature** — HMAC-SHA256 with the current secret and, during rotation, the previous secret still inside the grace window +4. **Timing-safe comparison** — Compare every active secret using constant-time equality. Failures never identify which key matched. +5. **Check timestamp** — Reject if outside 5-minute tolerance window (clock skew / replay) +6. **Persist nonce** — Reject reused nonces within the same window ### Signing Secret Rotation diff --git a/src/errors/codes.ts b/src/errors/codes.ts index b2c33c77..3a76b49a 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -189,6 +189,12 @@ export const ErrorCode = { /** Webhook signature verification failed */ INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE", + /** Webhook nonce header is malformed */ + MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE", + + /** Webhook nonce has already been used */ + WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED", + /** The delivery ID provided for webhook replay is missing or invalid */ INVALID_DELIVERY_ID: "INVALID_DELIVERY_ID", diff --git a/src/errors/errorCatalog.ts b/src/errors/errorCatalog.ts index 9e10f215..0cb8375c 100644 --- a/src/errors/errorCatalog.ts +++ b/src/errors/errorCatalog.ts @@ -82,6 +82,8 @@ export const ErrorCode = { WEBHOOK_TIMESTAMP_OUT_OF_WINDOW: "WEBHOOK_TIMESTAMP_OUT_OF_WINDOW", MALFORMED_WEBHOOK_SIGNATURE: "MALFORMED_WEBHOOK_SIGNATURE", INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE", + MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE", + WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED", INVALID_RETRY_POLICY: "INVALID_RETRY_POLICY", // IP allowlist diff --git a/src/openapi.yaml b/src/openapi.yaml index 811bdc5e..f8810a18 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -1540,10 +1540,13 @@ paths: description: > Receives a signed webhook event payload from an external system. The request must include an HMAC-SHA256 signature in the - `X-Callora-Signature-256` header and a Unix timestamp in the - `X-Callora-Timestamp` header. The signature is verified against all - active secrets (current and grace-period previous) before the payload - is processed. + `X-Callora-Signature-256` header, an ISO-8601 timestamp in + `X-Callora-Timestamp`, and a unique nonce in `X-Callora-Nonce`. + The signed payload is `..`. The signature + is verified against all active secrets (current and grace-period + previous) using constant-time comparison. Reused nonces and timestamps + outside the tolerance window are rejected. Failure responses never + identify which key matched. parameters: - name: developerId in: path @@ -1560,8 +1563,10 @@ paths: in: header required: true description: > - HMAC-SHA256 signature of the raw request body, prefixed with - `sha256=`. Computed as `sha256=`. + HMAC-SHA256 of `..`, prefixed with + `sha256=`. Compared in constant time against the current secret + and any previous secret still inside the rotation grace window. + Failure responses never identify which key matched. schema: type: string examples: @@ -1571,13 +1576,28 @@ paths: - name: X-Callora-Timestamp in: header required: true - description: Unix timestamp (seconds) of when the event was sent. + description: ISO-8601 timestamp of when the event was sent. Rejected when outside the configured skew window. schema: type: string examples: ts: summary: Example timestamp header - value: "1722074400" + value: "2026-07-27T09:30:00.000Z" + - name: X-Callora-Nonce + in: header + required: true + description: > + Unique request nonce (16–128 URL-safe characters). Bound into the + HMAC and persisted for the timestamp window so replays are rejected. + schema: + type: string + minLength: 16 + maxLength: 128 + pattern: "^[A-Za-z0-9._-]{16,128}$" + examples: + nonce: + summary: Example nonce header + value: "nonce-7c9e6679-7425-40de" requestBody: required: true content: @@ -1657,7 +1677,7 @@ paths: requestId: req-webhook-deliver-400-sig timestamp: "2026-07-27T09:31:00.000Z" "401": - description: HMAC signature verification failed. + description: HMAC signature verification failed, timestamp outside the skew window, or nonce replayed. content: application/json: schema: @@ -1672,6 +1692,15 @@ paths: message: Webhook signature verification failed requestId: req-webhook-deliver-401-invalid timestamp: "2026-07-27T09:32:00.000Z" + nonceReplayed: + summary: Nonce was already consumed (replay) + value: + success: false + error: + code: UNAUTHORIZED + message: Webhook signature verification failed + requestId: req-webhook-deliver-401-replay + timestamp: "2026-07-27T09:32:30.000Z" "404": description: No webhook registered for this developer. content: diff --git a/src/routes/webhooks.openapi.test.ts b/src/routes/webhooks.openapi.test.ts index 7a4887b2..e61a6b59 100644 --- a/src/routes/webhooks.openapi.test.ts +++ b/src/routes/webhooks.openapi.test.ts @@ -228,11 +228,12 @@ describe('src/openapi.yaml — POST deliver examples', () => { expect(content).toContain('$ref: "#/components/schemas/WebhookDeliveryResponse"'); }); - test('documents signature and timestamp header parameters', () => { + test('documents signature, timestamp, and nonce header parameters', () => { const content = readOpenApiYaml(); expect(content).toContain('X-Callora-Signature-256'); expect(content).toContain('X-Callora-Timestamp'); + expect(content).toContain('X-Callora-Nonce'); }); test('documents three delivery request examples covering all supported event types', () => { diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index bcd368f2..437d47f7 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -7,6 +7,7 @@ import { WebhookEventType, type RetryPolicy } from '../webhooks/webhook.types.js import { captureRawBody, verifyWebhookSignature, + parseCapturedJson, } from '../webhooks/webhook.signature.js'; import { AppError, BadRequestError, NotFoundError } from '../errors/index.js'; import { createRestRateLimitMiddleware } from '../middleware/restRateLimit.js'; @@ -288,7 +289,7 @@ router.post( next(); }, verifyWebhookSignature, - express.json(), + parseCapturedJson, (req: Request, res: Response) => { return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body }); } diff --git a/src/webhooks/webhook.deliver.test.ts b/src/webhooks/webhook.deliver.test.ts new file mode 100644 index 00000000..c1434ae8 --- /dev/null +++ b/src/webhooks/webhook.deliver.test.ts @@ -0,0 +1,196 @@ +/** + * HTTP-level coverage for inbound webhook delivery: + * rotation window, clock skew, nonce replay, malformed headers, and deletion. + */ + +import request from 'supertest'; +import express from 'express'; +import { + computeSignature, + SIGNATURE_HEADER, + TIMESTAMP_HEADER, + NONCE_HEADER, + SIGNATURE_TOLERANCE_MS, +} from './webhook.signature.js'; +import { WebhookStore } from './webhook.store.js'; +import { requestIdMiddleware } from '../middleware/requestId.js'; +import { errorHandler } from '../middleware/errorHandler.js'; + +jest.mock('../db.js', () => ({ + writeQuery: jest.fn().mockResolvedValue({ rows: [] }), +})); + +jest.mock('../logger.js', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + audit: jest.fn(), + }, + runWithRequestContext: (_ctx: unknown, callback: () => T): T => callback(), +})); + +import { createWebhooksRouter } from '../routes/webhooks.js'; + +function buildApp() { + const app = express(); + app.use(requestIdMiddleware); + app.use('/api/webhooks', createWebhooksRouter()); + app.use(errorHandler); + return app; +} + +function nonce(label: string): string { + return `nonce-${label}-0123456789ab`; +} + +function signedHeaders(secret: string, body: string, opts: { ts?: string; nonce?: string } = {}) { + const ts = opts.ts ?? new Date().toISOString(); + const n = opts.nonce ?? nonce('ok'); + return { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: n, + [SIGNATURE_HEADER]: `sha256=${computeSignature(secret, ts, body, n)}`, + }; +} + +const DELIVER_BODY = JSON.stringify({ + event: 'new_api_call', + timestamp: '2026-07-27T09:30:00.000Z', + developerId: 'dev-deliver', + data: { apiId: 'api-1' }, +}); + +describe('POST /api/webhooks/deliver/:developerId — rotation, skew, replay, deletion', () => { + let app: express.Express; + + beforeEach(() => { + app = buildApp(); + WebhookStore.clear(); + WebhookStore.register({ + developerId: 'dev-deliver', + url: 'https://example.com/webhook', + events: ['new_api_call'], + secret_current: 'current-secret', + createdAt: new Date(), + }); + }); + + async function deliver( + developerId: string, + headers: Record, + body: string = DELIVER_BODY, + ) { + return request(app) + .post(`/api/webhooks/deliver/${developerId}`) + .set(headers) + .set('Content-Type', 'application/json') + .send(body); + } + + it('accepts a delivery signed with the current key', async () => { + const res = await deliver('dev-deliver', signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('cur') })); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Webhook delivery accepted.'); + }); + + it('accepts both current and previous keys only inside the rotation window', async () => { + const expiresAt = new Date(Date.now() + 60_000); + WebhookStore.rotateSecret('dev-deliver', 'rotated-secret', expiresAt); + const stored = WebhookStore.get('dev-deliver')!; + + const previous = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('prev') }), + ); + const current = await deliver( + 'dev-deliver', + signedHeaders('rotated-secret', DELIVER_BODY, { nonce: nonce('new') }), + ); + expect(previous.status).toBe(200); + expect(current.status).toBe(200); + + stored.previous_expires_at = new Date(Date.now() - 1); + const expired = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('exp') }), + ); + expect(expired.status).toBe(401); + expect(expired.body.error?.code ?? expired.body.code).toBe('INVALID_WEBHOOK_SIGNATURE'); + expect(JSON.stringify(expired.body)).not.toContain('current-secret'); + expect(JSON.stringify(expired.body)).not.toContain('rotated-secret'); + expect(JSON.stringify(expired.body)).not.toMatch(/previous|matched key/i); + }); + + it('rejects timestamps outside the skew window', async () => { + const stale = new Date(Date.now() - SIGNATURE_TOLERANCE_MS - 2_000).toISOString(); + const future = new Date(Date.now() + SIGNATURE_TOLERANCE_MS + 2_000).toISOString(); + + const oldRes = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { ts: stale, nonce: nonce('stale') }), + ); + const futureRes = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { ts: future, nonce: nonce('future') }), + ); + + expect(oldRes.status).toBe(401); + expect(oldRes.body.error?.code ?? oldRes.body.code).toBe('WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); + expect(futureRes.status).toBe(401); + expect(futureRes.body.error?.code ?? futureRes.body.code).toBe('WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); + }); + + it('rejects a reused nonce as a replay', async () => { + const headers = signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('replay') }); + const first = await deliver('dev-deliver', headers); + const second = await deliver('dev-deliver', headers); + + expect(first.status).toBe(200); + expect(second.status).toBe(401); + expect(second.body.error?.code ?? second.body.code).toBe('WEBHOOK_NONCE_REPLAYED'); + expect(second.body.error?.message ?? second.body.message).toBe('Webhook signature verification failed.'); + }); + + it('rejects malformed signature and nonce headers', async () => { + const ts = new Date().toISOString(); + const missingPrefix = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce('malformed'), + [SIGNATURE_HEADER]: 'not-a-signature', + }); + expect(missingPrefix.status).toBe(400); + expect(missingPrefix.body.error?.code ?? missingPrefix.body.code).toBe('MALFORMED_WEBHOOK_SIGNATURE'); + + const badNonce = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: 'bad nonce!!', + [SIGNATURE_HEADER]: `sha256=${computeSignature('current-secret', ts, DELIVER_BODY, 'bad nonce!!')}`, + }); + expect(badNonce.status).toBe(400); + expect(badNonce.body.error?.code ?? badNonce.body.code).toBe('MALFORMED_WEBHOOK_NONCE'); + + const missing = await deliver('dev-deliver', { + [TIMESTAMP_HEADER]: ts, + [SIGNATURE_HEADER]: 'sha256=abcd', + }); + expect(missing.status).toBe(401); + expect(missing.body.error?.code ?? missing.body.code).toBe('MISSING_WEBHOOK_SIGNATURE_HEADERS'); + }); + + it('rejects deliveries after the webhook is deleted', async () => { + const headers = signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('del') }); + const before = await deliver('dev-deliver', headers); + expect(before.status).toBe(200); + + await request(app).delete('/api/webhooks/dev-deliver').expect(200); + + const after = await deliver( + 'dev-deliver', + signedHeaders('current-secret', DELIVER_BODY, { nonce: nonce('after-del') }), + ); + expect(after.status).toBe(404); + expect(after.body.error?.code ?? after.body.code).toBe('WEBHOOK_NOT_FOUND'); + }); +}); diff --git a/src/webhooks/webhook.nonceStore.test.ts b/src/webhooks/webhook.nonceStore.test.ts new file mode 100644 index 00000000..8c92a38b --- /dev/null +++ b/src/webhooks/webhook.nonceStore.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; + +describe('WebhookNonceStore', () => { + beforeEach(() => { + WebhookNonceStore.clear(); + }); + + it('consumes a nonce once and rejects reuse', () => { + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 60_000), true); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 60_000), false); + assert.equal(WebhookNonceStore.has('dev-1', 'nonce-abcdefghijklmnopqrst'), true); + }); + + it('scopes nonces per developer', () => { + assert.equal(WebhookNonceStore.consume('dev-a', 'nonce-abcdefghijklmnopqrst', 60_000), true); + assert.equal(WebhookNonceStore.consume('dev-b', 'nonce-abcdefghijklmnopqrst', 60_000), true); + }); + + it('allows reuse after TTL expiry', () => { + const now = 1_000_000; + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now), true); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now + 500), false); + assert.equal(WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 1_000, now + 1_001), true); + }); + + it('purgeScope deletes only that developer\'s nonces', () => { + WebhookNonceStore.consume('dev-a', 'nonce-abcdefghijklmnopqrst', 60_000); + WebhookNonceStore.consume('dev-b', 'nonce-abcdefghijklmnopqrst', 60_000); + WebhookNonceStore.purgeScope('dev-a'); + assert.equal(WebhookNonceStore.has('dev-a', 'nonce-abcdefghijklmnopqrst'), false); + assert.equal(WebhookNonceStore.has('dev-b', 'nonce-abcdefghijklmnopqrst'), true); + }); + + it('purgeExpired removes stale records', () => { + const now = 5_000; + WebhookNonceStore.consume('dev-1', 'nonce-abcdefghijklmnopqrst', 100, now); + WebhookNonceStore.purgeExpired(now + 101); + assert.equal(WebhookNonceStore.size(), 0); + }); +}); diff --git a/src/webhooks/webhook.nonceStore.ts b/src/webhooks/webhook.nonceStore.ts new file mode 100644 index 00000000..ba0112d4 --- /dev/null +++ b/src/webhooks/webhook.nonceStore.ts @@ -0,0 +1,64 @@ +/** + * In-memory nonce ledger for inbound webhook replay protection. + * + * Entries are scoped per developer so one subscriber cannot poison another's + * nonce space. TTL matches the signature timestamp window: once a timestamp + * would already be rejected as stale, the nonce can be forgotten. + */ + +const SEPARATOR = '\u0000'; + +const used = new Map(); + +function entryKey(scope: string, nonce: string): string { + return `${scope}${SEPARATOR}${nonce}`; +} + +export const WebhookNonceStore = { + /** + * Record a nonce as consumed. Returns false when the nonce is already + * persisted and has not yet expired (replay). + */ + consume(scope: string, nonce: string, ttlMs: number, now: number = Date.now()): boolean { + this.purgeExpired(now); + const key = entryKey(scope, nonce); + const expiresAt = used.get(key); + if (expiresAt !== undefined && expiresAt > now) { + return false; + } + used.set(key, now + ttlMs); + return true; + }, + + has(scope: string, nonce: string, now: number = Date.now()): boolean { + const expiresAt = used.get(entryKey(scope, nonce)); + return expiresAt !== undefined && expiresAt > now; + }, + + /** Drop every nonce belonging to a developer (called on webhook deletion). */ + purgeScope(scope: string): void { + const prefix = `${scope}${SEPARATOR}`; + for (const key of used.keys()) { + if (key.startsWith(prefix)) { + used.delete(key); + } + } + }, + + purgeExpired(now: number = Date.now()): void { + for (const [key, expiresAt] of used) { + if (expiresAt <= now) { + used.delete(key); + } + } + }, + + size(): number { + return used.size; + }, + + /** Test helper. */ + clear(): void { + used.clear(); + }, +}; diff --git a/src/webhooks/webhook.routes.ts b/src/webhooks/webhook.routes.ts index 4266acf8..8da38df5 100644 --- a/src/webhooks/webhook.routes.ts +++ b/src/webhooks/webhook.routes.ts @@ -7,6 +7,7 @@ import { WebhookEventType, type RetryPolicy } from './webhook.types.js'; import { captureRawBody, verifyWebhookSignature, + parseCapturedJson, } from './webhook.signature.js'; import { AppError, BadRequestError, NotFoundError } from '../errors/index.js'; import { createRestRateLimitMiddleware } from '../middleware/restRateLimit.js'; @@ -16,6 +17,13 @@ import { logger } from '../logger.js'; import { validateRetryPolicy } from '../services/webhookRetry.js'; import { createWebhookHealthRouter } from '../routes/webhooks/health.js'; import { securityHeadersMiddleware } from '../middleware/securityHeaders.js'; +import { validate } from '../middleware/validate.js'; +import { + registerWebhookSchema, + webhookDeveloperParamsSchema, + updateWebhookRetryPolicySchema, + webhookDeliveryPayloadSchema, +} from '../validators/webhooks.js'; const router = Router(); @@ -244,7 +252,7 @@ router.post( next(); }, verifyWebhookSignature, - express.json(), + parseCapturedJson, validate({ body: webhookDeliveryPayloadSchema }), (req: Request, res: Response) => { // Payload has been verified — safe to process diff --git a/src/webhooks/webhook.signature.test.ts b/src/webhooks/webhook.signature.test.ts index 8b23ac99..c50d1006 100644 --- a/src/webhooks/webhook.signature.test.ts +++ b/src/webhooks/webhook.signature.test.ts @@ -6,13 +6,17 @@ import type { Request, Response, NextFunction } from 'express'; import { computeSignature, safeCompare, + matchesAnySecret, verifyWebhookSignature, captureRawBody, + parseCapturedJson, SIGNATURE_HEADER, TIMESTAMP_HEADER, + NONCE_HEADER, SIGNATURE_TOLERANCE_MS, } from './webhook.signature.js'; import { WebhookStore } from './webhook.store.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; // --------------------------------------------------------------------------- // Helpers @@ -22,28 +26,59 @@ function makeTimestamp(offsetMs = 0): string { return new Date(Date.now() + offsetMs).toISOString(); } +function makeNonce(label = 'test'): string { + return `nonce-${label}-0123456789ab`; +} + /** Minimal Request stub — only the fields our middleware touches. */ function makeReq( overrides: Partial<{ headers: Record; webhookSecret: string; webhookSecrets: string[]; + webhookNonceScope: string; rawBody: Buffer; + params: Record; }> = {} -): Request & { webhookSecret?: string; webhookSecrets?: string[]; rawBody?: Buffer } { +): Request & { + webhookSecret?: string; + webhookSecrets?: string[]; + webhookNonceScope?: string; + rawBody?: Buffer; + params: Record; +} { const emitter = new EventEmitter() as unknown as Request & { webhookSecret?: string; webhookSecrets?: string[]; + webhookNonceScope?: string; rawBody?: Buffer; headers: Record; + params: Record; }; emitter.headers = overrides.headers ?? {}; emitter.webhookSecret = overrides.webhookSecret; emitter.webhookSecrets = overrides.webhookSecrets; + emitter.webhookNonceScope = overrides.webhookNonceScope; emitter.rawBody = overrides.rawBody; + emitter.params = overrides.params ?? { developerId: 'dev-test' }; return emitter; } +function signedHeaders( + secret: string, + body: Buffer | string, + opts: { ts?: string; nonce?: string } = {} +): Record { + const ts = opts.ts ?? makeTimestamp(); + const nonce = opts.nonce ?? makeNonce(); + const sig = computeSignature(secret, ts, body, nonce); + return { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce, + [SIGNATURE_HEADER]: `sha256=${sig}`, + }; +} + /** Minimal Response stub that records status + json calls. */ function makeRes(): Response & { _status: number; _body: unknown } { const res = { @@ -75,6 +110,10 @@ function collectNextError( return { nextCalled, error: capturedError }; } +beforeEach(() => { + WebhookNonceStore.clear(); +}); + // --------------------------------------------------------------------------- // computeSignature // --------------------------------------------------------------------------- @@ -120,6 +159,22 @@ test('computeSignature accepts a plain string body', () => { assert.equal(fromString, fromBuffer); }); +test('computeSignature includes nonce in the MAC when provided', () => { + const ts = '2026-01-01T00:00:00.000Z'; + const body = Buffer.from('body'); + const withoutNonce = computeSignature('secret', ts, body); + const withNonce = computeSignature('secret', ts, body, makeNonce()); + assert.notEqual(withoutNonce, withNonce); +}); + +test('computeSignature differs when nonce changes', () => { + const ts = '2026-01-01T00:00:00.000Z'; + const body = Buffer.from('body'); + const a = computeSignature('secret', ts, body, makeNonce('a')); + const b = computeSignature('secret', ts, body, makeNonce('b')); + assert.notEqual(a, b); +}); + // --------------------------------------------------------------------------- // safeCompare // --------------------------------------------------------------------------- @@ -167,7 +222,7 @@ test('verifyWebhookSignature rejects when signature header is missing', () => { const ts = makeTimestamp(); const req = makeReq({ webhookSecret: 'secret', - headers: { [TIMESTAMP_HEADER]: ts }, // no SIGNATURE_HEADER + headers: { [TIMESTAMP_HEADER]: ts, [NONCE_HEADER]: makeNonce() }, rawBody: Buffer.from('{}'), }); const res = makeRes(); @@ -180,7 +235,21 @@ test('verifyWebhookSignature rejects when signature header is missing', () => { test('verifyWebhookSignature rejects when timestamp header is missing', () => { const req = makeReq({ webhookSecret: 'secret', - headers: { [SIGNATURE_HEADER]: 'sha256=abc' }, // no TIMESTAMP_HEADER + headers: { [SIGNATURE_HEADER]: 'sha256=abc', [NONCE_HEADER]: makeNonce() }, + rawBody: Buffer.from('{}'), + }); + const res = makeRes(); + const { nextCalled, error } = collectNextError((next) => verifyWebhookSignature(req, res, next)); + assert.equal(nextCalled, true); + assert.equal((error as { name?: string }).name, 'UnauthorizedError'); + assert.equal((error as { code?: string }).code, 'MISSING_WEBHOOK_SIGNATURE_HEADERS'); +}); + +test('verifyWebhookSignature rejects when nonce header is missing', () => { + const ts = makeTimestamp(); + const req = makeReq({ + webhookSecret: 'secret', + headers: { [TIMESTAMP_HEADER]: ts, [SIGNATURE_HEADER]: 'sha256=abc' }, rawBody: Buffer.from('{}'), }); const res = makeRes(); @@ -195,6 +264,7 @@ test('verifyWebhookSignature rejects a non-ISO timestamp', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: 'not-a-date', + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=abc123', }, rawBody: Buffer.from('{}'), @@ -212,6 +282,7 @@ test('verifyWebhookSignature rejects a stale timestamp (too old)', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=deadbeef', }, rawBody: Buffer.from('{}'), @@ -229,6 +300,7 @@ test('verifyWebhookSignature rejects a future timestamp outside tolerance', () = webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'sha256=deadbeef', }, rawBody: Buffer.from('{}'), @@ -240,12 +312,21 @@ test('verifyWebhookSignature rejects a future timestamp outside tolerance', () = assert.equal((error as { code?: string }).code, 'WEBHOOK_TIMESTAMP_OUT_OF_WINDOW'); }); +test('verifyWebhookSignature accepts a timestamp inside the skew window', (done) => { + const body = Buffer.from('{"event":"new_api_call"}'); + const ts = makeTimestamp(-(SIGNATURE_TOLERANCE_MS - 5_000)); + const headers = signedHeaders('secret', body, { ts }); + const req = makeReq({ webhookSecret: 'secret', headers, rawBody: body }); + verifyWebhookSignature(req, makeRes(), () => { done(); }); +}); + test('verifyWebhookSignature rejects a malformed signature header (no prefix)', () => { const ts = makeTimestamp(); const req = makeReq({ webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'badhex', // missing sha256= prefix }, rawBody: Buffer.from('{}'), @@ -263,6 +344,7 @@ test('verifyWebhookSignature rejects a wrong prefix (md5=…)', () => { webhookSecret: 'secret', headers: { [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: makeNonce(), [SIGNATURE_HEADER]: 'md5=abc123', }, rawBody: Buffer.from('{}'), @@ -274,21 +356,53 @@ test('verifyWebhookSignature rejects a wrong prefix (md5=…)', () => { assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_SIGNATURE'); }); +test('verifyWebhookSignature rejects a malformed nonce', () => { + const body = Buffer.from('{}'); + const ts = makeTimestamp(); + const req = makeReq({ + webhookSecret: 'secret', + headers: { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: 'short', + [SIGNATURE_HEADER]: `sha256=${computeSignature('secret', ts, body, 'short')}`, + }, + rawBody: body, + }); + const res = makeRes(); + const { nextCalled, error } = collectNextError((next) => verifyWebhookSignature(req, res, next)); + assert.equal(nextCalled, true); + assert.equal((error as { name?: string }).name, 'BadRequestError'); + assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_NONCE'); +}); + +test('verifyWebhookSignature rejects a nonce with illegal characters', () => { + const body = Buffer.from('{}'); + const ts = makeTimestamp(); + const nonce = 'nonce with spaces!!!!'; + const req = makeReq({ + webhookSecret: 'secret', + headers: { + [TIMESTAMP_HEADER]: ts, + [NONCE_HEADER]: nonce, + [SIGNATURE_HEADER]: `sha256=${computeSignature('secret', ts, body, nonce)}`, + }, + rawBody: body, + }); + const { error } = collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + assert.equal((error as { code?: string }).code, 'MALFORMED_WEBHOOK_NONCE'); +}); + // --------------------------------------------------------------------------- // verifyWebhookSignature — signature mismatch // --------------------------------------------------------------------------- test('verifyWebhookSignature rejects when HMAC does not match', () => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const wrongHex = computeSignature('wrong-secret', ts, body); + const headers = signedHeaders('wrong-secret', body); const req = makeReq({ webhookSecret: 'correct-secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${wrongHex}`, - }, + headers, rawBody: body, }); const res = makeRes(); @@ -296,20 +410,18 @@ test('verifyWebhookSignature rejects when HMAC does not match', () => { assert.equal(nextCalled, true); assert.equal((error as { name?: string }).name, 'UnauthorizedError'); assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.equal((error as { message?: string }).message, 'Webhook signature verification failed.'); + assert.doesNotMatch((error as { message?: string }).message ?? '', /current|previous|key/i); }); test('verifyWebhookSignature rejects when body has been tampered with', () => { - const ts = makeTimestamp(); const originalBody = Buffer.from('{"event":"new_api_call"}'); const tamperedBody = Buffer.from('{"event":"settlement_completed"}'); - const sig = computeSignature('secret', ts, originalBody); + const headers = signedHeaders('secret', originalBody); const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers, rawBody: tamperedBody, }); const res = makeRes(); @@ -319,21 +431,34 @@ test('verifyWebhookSignature rejects when body has been tampered with', () => { assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); }); +test('failure response does not reveal which rotation key was tested', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const headers = signedHeaders('attacker-secret', body); + const req = makeReq({ + webhookSecrets: ['current-secret', 'previous-secret'], + headers, + rawBody: body, + }); + const { error } = collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + const serialized = JSON.stringify(error); + assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.equal((error as { message?: string }).message, 'Webhook signature verification failed.'); + assert.equal(serialized.includes('current-secret'), false); + assert.equal(serialized.includes('previous-secret'), false); + assert.equal(serialized.includes('matched'), false); + assert.equal('matchedKey' in (req as object), false); + assert.equal('webhookMatchedSecret' in (req as object), false); +}); + // --------------------------------------------------------------------------- // verifyWebhookSignature — happy path // --------------------------------------------------------------------------- test('verifyWebhookSignature calls next() for a valid signature', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('my-secret', ts, body); - const req = makeReq({ webhookSecret: 'my-secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('my-secret', body), rawBody: body, }); const res = makeRes(); @@ -341,16 +466,10 @@ test('verifyWebhookSignature calls next() for a valid signature', (done) => { }); test('verifyWebhookSignature accepts a signature from the current secret when multiple secrets are configured', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('current-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret', 'previous-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('current-secret', body), rawBody: body, }); const res = makeRes(); @@ -358,16 +477,10 @@ test('verifyWebhookSignature accepts a signature from the current secret when mu }); test('verifyWebhookSignature accepts a signature from the unexpired previous secret', (done) => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('previous-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret', 'previous-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('previous-secret', body), rawBody: body, }); const res = makeRes(); @@ -375,16 +488,10 @@ test('verifyWebhookSignature accepts a signature from the unexpired previous sec }); test('verifyWebhookSignature rejects a previous secret after its grace window is removed', () => { - const ts = makeTimestamp(); const body = Buffer.from('{"event":"new_api_call"}'); - const sig = computeSignature('previous-secret', ts, body); - const req = makeReq({ webhookSecrets: ['current-secret'], - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('previous-secret', body), rawBody: body, }); const res = makeRes(); @@ -392,9 +499,10 @@ test('verifyWebhookSignature rejects a previous secret after its grace window is assert.equal(nextCalled, true); assert.equal((error as { name?: string }).name, 'UnauthorizedError'); assert.equal((error as { code?: string }).code, 'INVALID_WEBHOOK_SIGNATURE'); + assert.doesNotMatch((error as { message?: string }).message ?? '', /previous/i); }); -test('WebhookStore.getActiveSecrets excludes the previous secret after previous_expires_at', () => { +test('WebhookStore.getActiveSecrets excludes and deletes the previous secret after previous_expires_at', () => { const config = { developerId: 'dev-expired', url: 'https://example.com/webhook', @@ -413,19 +521,15 @@ test('WebhookStore.getActiveSecrets excludes the previous secret after previous_ WebhookStore.getActiveSecrets(config, new Date('2026-06-25T12:00:01.000Z')), ['current-secret'], ); + assert.equal(config.secret_previous, undefined); + assert.equal(config.previous_expires_at, undefined); }); test('verifyWebhookSignature handles empty rawBody gracefully', (done) => { - const ts = makeTimestamp(); const body = Buffer.alloc(0); - const sig = computeSignature('secret', ts, body); - const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('secret', body), rawBody: body, }); const res = makeRes(); @@ -433,25 +537,73 @@ test('verifyWebhookSignature handles empty rawBody gracefully', (done) => { }); test('verifyWebhookSignature falls back to empty buffer when rawBody is undefined', (done) => { - const ts = makeTimestamp(); - const sig = computeSignature('secret', ts, Buffer.alloc(0)); - + const body = Buffer.alloc(0); const req = makeReq({ webhookSecret: 'secret', - headers: { - [TIMESTAMP_HEADER]: ts, - [SIGNATURE_HEADER]: `sha256=${sig}`, - }, + headers: signedHeaders('secret', body), // rawBody intentionally not set }); const res = makeRes(); verifyWebhookSignature(req, res, () => { done(); }); }); +test('matchesAnySecret returns true when any configured key matches', () => { + const body = Buffer.from('body'); + const ts = makeTimestamp(); + const nonce = makeNonce(); + const received = computeSignature('previous-secret', ts, body, nonce); + assert.equal( + matchesAnySecret(['current-secret', 'previous-secret', 'stale-secret'], ts, body, received, nonce), + true, + ); + assert.equal( + matchesAnySecret(['current-secret', 'stale-secret'], ts, body, received, nonce), + false, + ); +}); + +test('verifyWebhookSignature rejects a reused nonce as a replay', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const headers = signedHeaders('secret', body, { nonce: makeNonce('replay') }); + const first = makeReq({ + webhookSecret: 'secret', + headers, + rawBody: body, + params: { developerId: 'dev-replay' }, + }); + const second = makeReq({ + webhookSecret: 'secret', + headers, + rawBody: body, + params: { developerId: 'dev-replay' }, + }); + + const firstPass = collectNextError((next) => verifyWebhookSignature(first, makeRes(), next)); + assert.equal(firstPass.error, undefined); + + const replay = collectNextError((next) => verifyWebhookSignature(second, makeRes(), next)); + assert.equal((replay.error as { name?: string }).name, 'UnauthorizedError'); + assert.equal((replay.error as { code?: string }).code, 'WEBHOOK_NONCE_REPLAYED'); + assert.equal((replay.error as { message?: string }).message, 'Webhook signature verification failed.'); +}); + +test('verifyWebhookSignature does not persist a nonce when the signature is invalid', () => { + const body = Buffer.from('{"event":"new_api_call"}'); + const nonce = makeNonce('unauth'); + const headers = signedHeaders('wrong-secret', body, { nonce }); + const req = makeReq({ + webhookSecret: 'correct-secret', + headers, + rawBody: body, + params: { developerId: 'dev-unauth' }, + }); + collectNextError((next) => verifyWebhookSignature(req, makeRes(), next)); + assert.equal(WebhookNonceStore.has('dev-unauth', nonce), false); +}); + // --------------------------------------------------------------------------- // captureRawBody // --------------------------------------------------------------------------- - test('captureRawBody attaches raw bytes to req.rawBody', (done) => { const req = makeReq() as Request & { rawBody?: Buffer }; const res = makeRes(); @@ -493,3 +645,21 @@ test('captureRawBody forwards stream errors to next', (done) => { req.emit('error', boom); }); + +test('parseCapturedJson populates req.body from rawBody', (done) => { + const req = makeReq({ + rawBody: Buffer.from('{"event":"new_api_call"}'), + }) as Request & { rawBody?: Buffer; body?: unknown }; + parseCapturedJson(req, makeRes(), () => { + assert.deepEqual(req.body, { event: 'new_api_call' }); + done(); + }); +}); + +test('parseCapturedJson rejects invalid JSON', () => { + const req = makeReq({ + rawBody: Buffer.from('not-json'), + }) as Request & { rawBody?: Buffer }; + const { error } = collectNextError((next) => parseCapturedJson(req, makeRes(), next)); + assert.equal((error as { code?: string }).code, 'INVALID_BODY'); +}); diff --git a/src/webhooks/webhook.signature.ts b/src/webhooks/webhook.signature.ts index dceb0cb9..a0e91923 100644 --- a/src/webhooks/webhook.signature.ts +++ b/src/webhooks/webhook.signature.ts @@ -1,33 +1,47 @@ import crypto from 'crypto'; import type { Request, Response, NextFunction } from 'express'; import { BadRequestError, UnauthorizedError } from '../errors/index.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; export const SIGNATURE_HEADER = 'x-callora-signature-256'; export const TIMESTAMP_HEADER = 'x-callora-timestamp'; +export const NONCE_HEADER = 'x-callora-nonce'; /** * Maximum age (ms) of a webhook request before it is rejected as a replay. - * Default: 5 minutes. + * Default: 5 minutes. Nonce records use the same TTL. */ export const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; +/** Nonce must be unique, URL-safe, and long enough to resist guessing. */ +const NONCE_PATTERN = /^[A-Za-z0-9._-]{16,128}$/; + +const GENERIC_SIGNATURE_FAILURE = 'Webhook signature verification failed.'; + /** * Compute the expected HMAC-SHA256 signature for a webhook delivery. * - * The signed payload is: `.` - * This ties the signature to both the content and the delivery time, - * preventing replay attacks even when the same payload is re-sent. + * The signed payload is: `..` when a nonce is + * supplied, otherwise `.` (legacy callers / unit tests). + * Binding timestamp and nonce into the MAC prevents swapping either field + * on a captured request. * * @param secret - Shared secret stored at registration time. * @param timestamp - ISO-8601 delivery timestamp (from x-callora-timestamp header). * @param rawBody - Raw request body bytes (Buffer or string). + * @param nonce - Unique request nonce (from x-callora-nonce header). */ export function computeSignature( secret: string, timestamp: string, - rawBody: Buffer | string + rawBody: Buffer | string, + nonce?: string ): string { - const payload = `${timestamp}.${rawBody.toString()}`; + const body = rawBody.toString(); + const payload = + nonce !== undefined && nonce.length > 0 + ? `${timestamp}.${nonce}.${body}` + : `${timestamp}.${body}`; return crypto.createHmac('sha256', secret).update(payload).digest('hex'); } @@ -41,6 +55,48 @@ export function safeCompare(a: string, b: string): boolean { return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); } +/** + * Compare `receivedHex` against every provided secret without short-circuiting. + * Always walks the full list so the matched key cannot be inferred from timing + * or from the failure response. + * + * @returns true when at least one secret matches; never identifies which one. + */ +export function matchesAnySecret( + secrets: readonly string[], + timestamp: string, + rawBody: Buffer | string, + receivedHex: string, + nonce?: string +): boolean { + let matched = 0; + for (const secret of secrets) { + const expectedHex = computeSignature(secret, timestamp, rawBody, nonce); + if (safeCompare(expectedHex, receivedHex)) { + matched = 1; + } + } + return matched === 1; +} + +function headerValue(value: string | string[] | undefined): string | undefined { + if (typeof value === 'string') return value; + if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; + return undefined; +} + +function nonceScope( + req: Request & { webhookNonceScope?: string } +): string { + if (typeof req.webhookNonceScope === 'string' && req.webhookNonceScope.length > 0) { + return req.webhookNonceScope; + } + const developerId = req.params?.developerId; + return typeof developerId === 'string' && developerId.length > 0 + ? developerId + : '_'; +} + /** * Express middleware: verify the HMAC-SHA256 signature on incoming webhook deliveries. * @@ -50,6 +106,7 @@ export function safeCompare(a: string, b: string): boolean { * - or `req.webhookSecrets` (string[]) containing current and unexpired previous secrets. * - `x-callora-signature-256` header — `sha256=` * - `x-callora-timestamp` header — ISO-8601 string + * - `x-callora-nonce` header — unique request nonce * - `req.rawBody` (Buffer) — populated by the `captureRawBody` middleware. * * If the secret is absent the middleware is a no-op (backwards compatible with @@ -58,10 +115,19 @@ export function safeCompare(a: string, b: string): boolean { * Rejects with 401 when: * - Headers are missing * - Timestamp is stale (> SIGNATURE_TOLERANCE_MS) - * - Signature does not match + * - Signature does not match any active key + * - Nonce has already been consumed + * + * Failure responses never identify which key (current vs previous) was tested + * or matched. */ export function verifyWebhookSignature( - req: Request & { webhookSecret?: string; webhookSecrets?: string[]; rawBody?: Buffer }, + req: Request & { + webhookSecret?: string; + webhookSecrets?: string[]; + webhookNonceScope?: string; + rawBody?: Buffer; + }, _res: Response, next: NextFunction ): void { @@ -72,12 +138,13 @@ export function verifyWebhookSignature( return next(); } - const sigHeader = req.headers[SIGNATURE_HEADER] as string | undefined; - const tsHeader = req.headers[TIMESTAMP_HEADER] as string | undefined; + const sigHeader = headerValue(req.headers[SIGNATURE_HEADER]); + const tsHeader = headerValue(req.headers[TIMESTAMP_HEADER]); + const nonceHeader = headerValue(req.headers[NONCE_HEADER]); - if (!sigHeader || !tsHeader) { + if (!sigHeader || !tsHeader || !nonceHeader) { next(new UnauthorizedError( - `Missing required headers: ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER}.`, + `Missing required headers: ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER}, ${NONCE_HEADER}.`, 'MISSING_WEBHOOK_SIGNATURE_HEADERS' )); return; @@ -112,20 +179,38 @@ export function verifyWebhookSignature( } const receivedHex = parts[1]; + if (!NONCE_PATTERN.test(nonceHeader)) { + next(new BadRequestError( + `Malformed ${NONCE_HEADER} header.`, + 'MALFORMED_WEBHOOK_NONCE' + )); + return; + } + const rawBody = req.rawBody ?? Buffer.alloc(0); - const hasValidSignature = secrets.some((secret) => { - const expectedHex = computeSignature(secret, tsHeader, rawBody); - return safeCompare(expectedHex, receivedHex); - }); + const accepted = matchesAnySecret(secrets, tsHeader, rawBody, receivedHex, nonceHeader); - if (!hasValidSignature) { + if (!accepted) { next(new UnauthorizedError( - 'Webhook signature verification failed.', + GENERIC_SIGNATURE_FAILURE, 'INVALID_WEBHOOK_SIGNATURE' )); return; } + const consumed = WebhookNonceStore.consume( + nonceScope(req), + nonceHeader, + SIGNATURE_TOLERANCE_MS + ); + if (!consumed) { + next(new UnauthorizedError( + GENERIC_SIGNATURE_FAILURE, + 'WEBHOOK_NONCE_REPLAYED' + )); + return; + } + next(); } @@ -154,3 +239,26 @@ export function captureRawBody( }); req.on('error', next); } + +/** + * Parse JSON from `req.rawBody` after `captureRawBody` has consumed the stream. + * `express.json()` cannot re-read the request once the raw bytes are buffered. + */ +export function parseCapturedJson( + req: Request & { rawBody?: Buffer }, + _res: Response, + next: NextFunction +): void { + if (!req.rawBody || req.rawBody.length === 0) { + req.body = {}; + next(); + return; + } + + try { + req.body = JSON.parse(req.rawBody.toString()); + next(); + } catch { + next(new BadRequestError('Invalid JSON body.', 'INVALID_BODY')); + } +} diff --git a/src/webhooks/webhook.store.test.ts b/src/webhooks/webhook.store.test.ts new file mode 100644 index 00000000..b3b1452d --- /dev/null +++ b/src/webhooks/webhook.store.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { WebhookStore } from './webhook.store.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; +import type { WebhookConfig } from './webhook.types.js'; + +function baseConfig(overrides: Partial = {}): WebhookConfig { + return { + developerId: 'dev-store', + url: 'https://example.com/webhook', + events: ['new_api_call'], + createdAt: new Date('2026-06-25T11:00:00.000Z'), + ...overrides, + }; +} + +describe('WebhookStore rotation + deletion', () => { + beforeEach(() => { + WebhookStore.clear(); + }); + + it('rotateSecret keeps current and previous keys inside the grace window', () => { + WebhookStore.register(baseConfig({ + developerId: 'dev-rot', + secret_current: 'old-secret', + })); + + const expiresAt = new Date('2026-06-26T12:00:00.000Z'); + const rotated = WebhookStore.rotateSecret('dev-rot', 'new-secret', expiresAt); + assert.ok(rotated); + assert.equal(rotated.secret_current, 'new-secret'); + assert.equal(rotated.secret_previous, 'old-secret'); + + const now = new Date('2026-06-26T11:59:59.000Z'); + assert.deepEqual(WebhookStore.getActiveSecrets(rotated, now), ['new-secret', 'old-secret']); + }); + + it('getActiveSecrets deletes the previous key after the rotation window', () => { + const config = baseConfig({ + secret_current: 'new-secret', + secret_previous: 'old-secret', + previous_expires_at: new Date('2026-06-26T12:00:00.000Z'), + }); + WebhookStore.register(config); + + const stored = WebhookStore.get('dev-store')!; + const afterWindow = new Date('2026-06-26T12:00:01.000Z'); + assert.deepEqual(WebhookStore.getActiveSecrets(stored, afterWindow), ['new-secret']); + assert.equal(stored.secret_previous, undefined); + assert.equal(stored.previous_expires_at, undefined); + }); + + it('delete removes the webhook and purges persisted nonces', () => { + WebhookStore.register(baseConfig({ + secret_current: 'secret', + })); + assert.equal(WebhookNonceStore.consume('dev-store', 'nonce-abcdefghijklmnopqrst', 60_000), true); + + WebhookStore.delete('dev-store'); + assert.equal(WebhookStore.get('dev-store'), undefined); + assert.equal(WebhookNonceStore.has('dev-store', 'nonce-abcdefghijklmnopqrst'), false); + }); + + it('a second rotation replaces the previous key rather than accumulating', () => { + WebhookStore.register(baseConfig({ + developerId: 'dev-double', + secret_current: 's0', + })); + const t1 = new Date('2026-06-26T12:00:00.000Z'); + WebhookStore.rotateSecret('dev-double', 's1', t1); + const t2 = new Date('2026-06-27T12:00:00.000Z'); + WebhookStore.rotateSecret('dev-double', 's2', t2); + + const stored = WebhookStore.get('dev-double')!; + assert.equal(stored.secret_current, 's2'); + assert.equal(stored.secret_previous, 's1'); + assert.deepEqual( + WebhookStore.getActiveSecrets(stored, new Date('2026-06-26T12:00:00.000Z')), + ['s2', 's1'], + ); + }); +}); diff --git a/src/webhooks/webhook.store.ts b/src/webhooks/webhook.store.ts index 6d3518bf..44c33aaf 100644 --- a/src/webhooks/webhook.store.ts +++ b/src/webhooks/webhook.store.ts @@ -1,4 +1,5 @@ import { WebhookConfig, WebhookEventType, DeadLetterEntry, type RetryPolicy } from './webhook.types.js'; +import { WebhookNonceStore } from './webhook.nonceStore.js'; const store = new Map(); const deadLetterStore = new Map(); @@ -87,7 +88,29 @@ export const WebhookStore = { return nextConfig; }, + /** + * Drop the previous signing key once its grace window has elapsed. + * Mutates `config` in place (the object held in the store). + * @returns true when a previous key was deleted. + */ + expirePreviousSecret(config: WebhookConfig, now: Date = new Date()): boolean { + const expiredByClock = + !!config.previous_expires_at && + config.previous_expires_at.getTime() < now.getTime(); + const orphaned = !!config.secret_previous && !config.previous_expires_at; + + if (!config.secret_previous || (!expiredByClock && !orphaned)) { + return false; + } + + delete config.secret_previous; + delete config.previous_expires_at; + return true; + }, + getActiveSecrets(config: WebhookConfig, now: Date = new Date()): string[] { + this.expirePreviousSecret(config, now); + const secrets = new Set(); const currentSecret = config.secret_current ?? config.secret; @@ -108,6 +131,7 @@ export const WebhookStore = { delete(developerId: string): void { store.delete(developerId); + WebhookNonceStore.purgeScope(developerId); }, getByEvent(event: WebhookEventType): WebhookConfig[] { @@ -121,6 +145,7 @@ export const WebhookStore = { /** Clear all webhook configurations - for testing only */ clear(): void { store.clear(); + WebhookNonceStore.clear(); }, // ── Dead-Letter Queue (DLQ) ─────────────────────────────────────────────