diff --git a/WEBHOOK_SIGNATURE_VERIFICATION.md b/WEBHOOK_SIGNATURE_VERIFICATION.md index 79d5df43..8f8341bf 100644 --- a/WEBHOOK_SIGNATURE_VERIFICATION.md +++ b/WEBHOOK_SIGNATURE_VERIFICATION.md @@ -6,8 +6,11 @@ Canonical guide: docs/webhooks.md Outbound Callora deliveries (dispatchWebhook) use: Header: X-Callora-Signature: sha256= +Key ID header: X-Callora-Key-Id: (identifies the active managed signing key from WebhookSignerService, or subscription id fallback) MAC input: raw HTTP body bytes only (timestamp is not signed) -No signature header when the subscription has no secret +Automatic secret provisioning: Registering a webhook via POST /api/webhooks without a secret automatically generates a 64-hex-char signing secret, returns it once in the 201 response body, and signs all subsequent deliveries with X-Callora-Signature. Caller-supplied secrets shorter than 32 characters are rejected with HTTP 400. +Managed rotation: When platform signing keys are rotated via POST /api/admin/webhooks/rotate-key, new outbound deliveries immediately sign with the new active key and emit its id in X-Callora-Key-Id, while the previous key remains valid for verification during the configured grace window (WEBHOOK_SECRET_ROTATION_GRACE_MS). +No signature header when neither a managed active key nor a subscription secret is configured Do not use the older X-Callora-Signature-256 / .. examples from previous revisions of this file for verifying events Callora sends to your endpoint — that scheme is the platform-inbound deliver route only. diff --git a/docs/webhooks.md b/docs/webhooks.md index 1ab20acf..c6d9ae69 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -11,10 +11,9 @@ Field Type Required Description developerId string ✅ Your developer ID url string ✅ HTTPS endpoint to receive events events string[] ✅ One or more event types (see below) -secret string ❌ Used to sign payloads (recommended) +secret string ❌ Used to sign payloads (min 32 characters; auto-generated as 64 hex chars and returned once in the 201 response when omitted) retryPolicy object optional Optional per-subscription retry override -Request bodies are Zod-validated before registration logic runs. Unknown fields -are rejected. +Request bodies are validated before registration logic runs. If `secret` is omitted, Callora automatically generates a 32-byte (64-character hex) signing secret, stores it on the subscription, and returns it once in the `201 Created` response body (`secret`). Store it immediately — `GET /api/webhooks/:developerId` never returns signing secrets. If a caller supplies `secret`, it must be at least 32 characters long or registration fails with `400 Bad Request` (`INVALID_WEBHOOK_SECRET`). Validation errors Invalid registration and retry-policy requests return HTTP 400 using the diff --git a/src/routes/webhooks.test.ts b/src/routes/webhooks.test.ts index b5ea7402..bec4ca39 100644 --- a/src/routes/webhooks.test.ts +++ b/src/routes/webhooks.test.ts @@ -420,4 +420,109 @@ describe('Webhook management routes — Authentication & Authorization (Issue #1 assert.equal(res.body.developerId, 'dev-bob'); }); }); + + describe('Automatic signing secret generation on registration (Issue #1211)', () => { + it('returns a 64-hex-char secret when registered without a secret and stores it on the subscription', async () => { + const res = await request(app) + .post('/api/webhooks') + .set(authHeader('dev-auto-secret')) + .send({ + developerId: 'dev-auto-secret', + url: 'https://example.com/webhook', + events: ['new_api_call'], + }); + + assert.equal(res.status, 201); + assert.equal(typeof res.body.secret, 'string'); + assert.match(res.body.secret, /^[a-f0-9]{64}$/); + + const stored = WebhookStore.get('dev-auto-secret'); + assert.ok(stored); + assert.equal(stored.secret_current, res.body.secret); + assert.equal(stored.secret, res.body.secret); + }); + + it('attaches X-Callora-Signature on subsequent deliveries for a subscription registered without a secret', async () => { + const res = await request(app) + .post('/api/webhooks') + .set(authHeader('dev-auto-dispatch')) + .send({ + developerId: 'dev-auto-dispatch', + url: 'https://example.com/webhook', + events: ['settlement_completed'], + }); + + assert.equal(res.status, 201); + const generatedSecret = res.body.secret as string; + const stored = WebhookStore.get('dev-auto-dispatch')!; + + const { dispatchWebhook } = await import('../webhooks/webhook.dispatcher.js'); + const { signWithSecret } = await import('../services/webhookSigner.js'); + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as unknown as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + try { + const payload = { + event: 'settlement_completed' as const, + timestamp: new Date().toISOString(), + developerId: 'dev-auto-dispatch', + data: { settlementId: 's-1', amount: '10.00' }, + }; + await dispatchWebhook(stored, payload); + + assert.equal(fetchMock.mock.calls.length, 1); + const [, init] = fetchMock.mock.calls[0]!; + const headers = (init as RequestInit).headers as Record; + const body = (init as RequestInit).body as string; + assert.equal( + headers['X-Callora-Signature'], + `sha256=${signWithSecret(generatedSecret, body)}`, + ); + } finally { + global.fetch = originalFetch; + } + }); + + it('rejects a caller-supplied secret shorter than 32 characters with 400', async () => { + const res = await request(app) + .post('/api/webhooks') + .set(authHeader('dev-short-secret')) + .send({ + developerId: 'dev-short-secret', + url: 'https://example.com/webhook', + events: ['new_api_call'], + secret: 'too-short-secret-123', + }); + + assert.equal(res.status, 400); + assert.equal(WebhookStore.get('dev-short-secret'), undefined); + }); + + it('never returns the secret on GET /api/webhooks/:developerId', async () => { + const regRes = await request(app) + .post('/api/webhooks') + .set(authHeader('dev-no-leak')) + .send({ + developerId: 'dev-no-leak', + url: 'https://example.com/webhook', + events: ['new_api_call'], + }); + assert.equal(regRes.status, 201); + assert.match(regRes.body.secret, /^[a-f0-9]{64}$/); + + const getRes = await request(app) + .get('/api/webhooks/dev-no-leak') + .set(authHeader('dev-no-leak')); + + assert.equal(getRes.status, 200); + assert.equal(getRes.body.secret, undefined); + assert.equal(getRes.body.secret_current, undefined); + assert.equal(getRes.body.secret_previous, undefined); + }); + }); }); \ No newline at end of file diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 582abfd4..cd6a81cd 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -191,6 +191,15 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res: ); } + if (secret !== undefined && secret !== null) { + if (typeof secret !== 'string' || secret.length < 32) { + throw new BadRequestError( + 'Webhook secret must be at least 32 characters long.', + 'INVALID_WEBHOOK_SECRET' + ); + } + } + const invalidEvents = events.filter( (e: string) => !VALID_EVENTS.includes(e as WebhookEventType) ); @@ -219,13 +228,19 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res: throw new AppError('URL validation failed.', 500, 'WEBHOOK_URL_VALIDATION_FAILED'); } + const effectiveSecret = + typeof secret === 'string' && secret.length >= 32 + ? secret + : generateWebhookSecret(); + const before = WebhookStore.get(developerId) ? sanitizeConfig(WebhookStore.get(developerId) as unknown as Record) : null; WebhookStore.register({ developerId, url, events: events as WebhookEventType[], - secret_current: secret ?? undefined, + secret: effectiveSecret, + secret_current: effectiveSecret, retryPolicy: retryPolicy as RetryPolicy | undefined, createdAt: new Date(), }); @@ -239,6 +254,7 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res: developerId, url, events, + secret: effectiveSecret, }); } catch (error) { next(error); diff --git a/src/webhooks/webhook.dispatcher.test.ts b/src/webhooks/webhook.dispatcher.test.ts index 97bfd898..219cd696 100644 --- a/src/webhooks/webhook.dispatcher.test.ts +++ b/src/webhooks/webhook.dispatcher.test.ts @@ -804,5 +804,98 @@ describe('Webhook Dispatcher', () => { expect(callerA).not.toEqual(callerB); }); }); + + describe('managed rotation key signing (#1234)', () => { + it('signs deliveries with managed active key, emits X-Callora-Key-Id, and switches to new key after rotation while keeping previous key verifiable during grace window', async () => { + const { + InMemoryWebhookKeyStore, + WebhookSignerService, + signWithSecret, + } = await import('../services/webhookSigner.js'); + const { setWebhookSignerService } = await import('./webhook.dispatcher.js'); + + const keyStore = new InMemoryWebhookKeyStore(); + let now = new Date('2026-06-01T12:00:00.000Z'); + const signer = new WebhookSignerService({ + store: keyStore, + graceWindowMs: 60_000, + now: () => now, + }); + setWebhookSignerService(signer); + + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const rotation1 = await signer.rotateKey('admin-1'); + await dispatchWebhook(config, payload); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstHeaders = fetchMock.mock.calls[0][1].headers as Record; + const firstBody = fetchMock.mock.calls[0][1].body as string; + expect(firstHeaders['X-Callora-Key-Id']).toBe(rotation1.newKey.id); + expect(firstHeaders['X-Callora-Signature']).toBe( + `sha256=${signWithSecret(rotation1.rawSecret, firstBody)}`, + ); + + // Rotate key again; subsequent delivery uses the new active key + now = new Date(now.getTime() + 1_000); + const rotation2 = await signer.rotateKey('admin-1'); + await dispatchWebhook(config, payload); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondHeaders = fetchMock.mock.calls[1][1].headers as Record; + const secondBody = fetchMock.mock.calls[1][1].body as string; + expect(secondHeaders['X-Callora-Key-Id']).toBe(rotation2.newKey.id); + expect(secondHeaders['X-Callora-Signature']).toBe( + `sha256=${signWithSecret(rotation2.rawSecret, secondBody)}`, + ); + + // Both first (previous key) and second (active key) deliveries verify during grace window + const verifyFirst = await signer.verifyPayloadSignature( + firstBody, + firstHeaders['X-Callora-Signature'], + now, + ); + const verifySecond = await signer.verifyPayloadSignature( + secondBody, + secondHeaders['X-Callora-Signature'], + now, + ); + expect(verifyFirst).toEqual({ valid: true, keyId: rotation1.newKey.id }); + expect(verifySecond).toEqual({ valid: true, keyId: rotation2.newKey.id }); + }); + + it('signs deliveries with config.secret_current when no managed platform key is active (#1211)', async () => { + const { signWithSecret } = await import('../services/webhookSigner.js'); + const { resolveWebhookSigningSecret } = await import('./webhook.dispatcher.js'); + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const autoSecret = 'a'.repeat(64); + const configWithSecretCurrentOnly: WebhookConfig = { + ...config, + secret_current: autoSecret, + }; + + expect(resolveWebhookSigningSecret(configWithSecretCurrentOnly)).toBe(autoSecret); + + await dispatchWebhook(configWithSecretCurrentOnly, payload); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const headers = fetchMock.mock.calls[0][1].headers as Record; + const body = fetchMock.mock.calls[0][1].body as string; + expect(headers['X-Callora-Signature']).toBe( + `sha256=${signWithSecret(autoSecret, body)}`, + ); + }); + }); }); diff --git a/src/webhooks/webhook.dispatcher.ts b/src/webhooks/webhook.dispatcher.ts index f8fa54a3..a4f565bd 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -6,6 +6,12 @@ import { getCorrelationId, getRequestId } from '../utils/asyncContext.js'; import { getEffectiveRetryPolicy, calculateBackoff } from '../services/webhookRetry.js'; import { computeJitteredDelay, type RandomSource } from '../lib/retry.js'; import { validateWebhookUrl, WebhookValidationError } from './webhook.validator.js'; +import { + defaultWebhookKeyStore, + defaultWebhookSignerService, + signWithSecret, + type WebhookSignerService, +} from '../services/webhookSigner.js'; export const MAX_WEBHOOK_RESPONSE_BYTES = 64 * 1024; @@ -71,6 +77,7 @@ export async function consumeCappedResponseBody( let acceptingDispatches = true; const inFlightDispatches = new Set>(); +let activeSignerService: WebhookSignerService = defaultWebhookSignerService; /** * Random source used for retry backoff jitter. Injectable so tests can supply @@ -82,10 +89,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function signPayload(secret: string, body: string): string { - return crypto.createHmac('sha256', secret).update(body).digest('hex'); -} - function trackDispatch(operation: Promise): Promise { const tracked = operation.finally(() => { inFlightDispatches.delete(tracked as Promise); @@ -105,10 +108,16 @@ export async function awaitWebhookDispatcherIdle(): Promise { } } +export function setWebhookSignerService(signer: WebhookSignerService): void { + activeSignerService = signer; +} + export function resetWebhookDispatcherForTests(): void { acceptingDispatches = true; inFlightDispatches.clear(); jitterRandom = Math.random; + activeSignerService = defaultWebhookSignerService; + defaultWebhookKeyStore._reset(); } /** @@ -119,6 +128,15 @@ export function setWebhookJitterRandom(random: RandomSource): void { jitterRandom = random; } +/** + * Resolves the active per-subscription signing secret from either + * `secret_current` (registered / rotated secret) or legacy `secret`. + */ +export function resolveWebhookSigningSecret(config: WebhookConfig): string | undefined { + const candidate = config.secret_current ?? config.secret; + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined; +} + /** * Dispatches a webhook payload to the registered URL. * @@ -160,8 +178,18 @@ export async function dispatchWebhook( headers['X-Correlation-Id'] = correlationId; } - if (config.secret) { - headers['X-Callora-Signature'] = `sha256=${signPayload(config.secret, body)}`; + const managedSig = await activeSignerService.signPayload(body); + if (managedSig) { + headers['X-Callora-Signature'] = managedSig.signature; + headers['X-Callora-Key-Id'] = managedSig.keyId; + } else { + const subSecret = resolveWebhookSigningSecret(config); + if (subSecret) { + headers['X-Callora-Signature'] = `sha256=${signWithSecret(subSecret, body)}`; + if (config.id) { + headers['X-Callora-Key-Id'] = config.id; + } + } } let lastError: unknown;