Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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: 4 additions & 1 deletion WEBHOOK_SIGNATURE_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ Canonical guide: docs/webhooks.md
Outbound Callora deliveries (dispatchWebhook) use:

Header: X-Callora-Signature: sha256=<hex>
Key ID header: X-Callora-Key-Id: <uuid> (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 / <timestamp>.<nonce>.<rawBody>
examples from previous revisions of this file for verifying events Callora
sends to your endpoint — that scheme is the platform-inbound deliver route only.
Expand Down
5 changes: 2 additions & 3 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions src/routes/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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);
});
});
});
18 changes: 17 additions & 1 deletion src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down Expand Up @@ -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<string, unknown>) : 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(),
});
Expand All @@ -239,6 +254,7 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res:
developerId,
url,
events,
secret: effectiveSecret,
});
} catch (error) {
next(error);
Expand Down
93 changes: 93 additions & 0 deletions src/webhooks/webhook.dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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<string, string>;
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<string, string>;
const body = fetchMock.mock.calls[0][1].body as string;
expect(headers['X-Callora-Signature']).toBe(
`sha256=${signWithSecret(autoSecret, body)}`,
);
});
});
});

40 changes: 34 additions & 6 deletions src/webhooks/webhook.dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -71,6 +77,7 @@ export async function consumeCappedResponseBody(

let acceptingDispatches = true;
const inFlightDispatches = new Set<Promise<void>>();
let activeSignerService: WebhookSignerService = defaultWebhookSignerService;

/**
* Random source used for retry backoff jitter. Injectable so tests can supply
Expand All @@ -82,10 +89,6 @@ function sleep(ms: number): Promise<void> {
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<T>(operation: Promise<T>): Promise<T> {
const tracked = operation.finally(() => {
inFlightDispatches.delete(tracked as Promise<void>);
Expand All @@ -105,10 +108,16 @@ export async function awaitWebhookDispatcherIdle(): Promise<void> {
}
}

export function setWebhookSignerService(signer: WebhookSignerService): void {
activeSignerService = signer;
}

export function resetWebhookDispatcherForTests(): void {
acceptingDispatches = true;
inFlightDispatches.clear();
jitterRandom = Math.random;
activeSignerService = defaultWebhookSignerService;
defaultWebhookKeyStore._reset();
}

/**
Expand All @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down