From d915f0fd5600d8f8f253d66eafeac2462153eaaf Mon Sep 17 00:00:00 2001 From: Joyerin007 Date: Sun, 30 Aug 2026 20:37:09 +0000 Subject: [PATCH 1/2] feat(webhooks): implement robust webhook delivery with HMAC signing and exponential backoff - Add generateWebhookSignature (timestamp + body -> HMAC-SHA256) and buildWebhookHeaders in crypto.util - Add X-Astroid-Delivery and X-Astroid-Event headers constants - Update WebhooksProcessor and WebhookWorker to sign timestamp-concatenated body, inject ConfigService fallback (WEBHOOK_SECRET / STELLAR_WEBHOOK_SECRET), include X-Astroid-Signature, X-Astroid-Delivery, X-Astroid-Event, X-Astroid-Timestamp headers with 5000ms fetch timeout - Configure BullMQ retries up to 5 times with exponential backoff (2000ms base) and jitter - Add webhook.service.spec.ts covering signature generation, header formatting, and retry behavior - Update existing processor specs for new signing scheme Closes # --- src/common/constants/headers.ts | 2 + src/modules/webhooks/webhook.service.spec.ts | 238 ++++++++++++++++++ .../webhooks/webhooks.processor.spec.ts | 9 +- src/modules/webhooks/webhooks.processor.ts | 26 +- .../webhooks/workers/webhook.worker.ts | 26 +- src/utils/crypto.util.ts | 32 +++ 6 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 src/modules/webhooks/webhook.service.spec.ts diff --git a/src/common/constants/headers.ts b/src/common/constants/headers.ts index 1aaa4e9..bdf7310 100644 --- a/src/common/constants/headers.ts +++ b/src/common/constants/headers.ts @@ -5,4 +5,6 @@ export const API_KEY_HEADER = 'x-api-key'; export const WEBHOOK_SIGNATURE_HEADER = 'x-astroid-signature'; export const WEBHOOK_TIMESTAMP_HEADER = 'x-astroid-timestamp'; export const WEBHOOK_EVENT_ID_HEADER = 'x-astroid-event-id'; +export const WEBHOOK_DELIVERY_HEADER = 'x-astroid-delivery'; +export const WEBHOOK_EVENT_HEADER = 'x-astroid-event'; export const IDEMPOTENCY_KEY_HEADER = 'idempotency-key'; diff --git a/src/modules/webhooks/webhook.service.spec.ts b/src/modules/webhooks/webhook.service.spec.ts new file mode 100644 index 0000000..e63d62f --- /dev/null +++ b/src/modules/webhooks/webhook.service.spec.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHmac } from 'crypto'; +import { Job, UnrecoverableError } from 'bullmq'; +import { WebhooksProcessor } from './webhooks.processor'; +import { WebhookJobData } from './types/webhook-job.types'; +import { generateWebhookSignature, buildWebhookHeaders, hmacSign } from '../../utils/crypto.util'; + +/** + * Tests for webhook cryptographic signing and delivery header formatting. + * Mirrors the acceptance criteria: HMAC-SHA256 signing with + * timestamp + JSON body, standard headers, exponential backoff retries, + * ConfigService fallback secret, and 5s HTTP timeout. + */ +describe('Webhook signing & delivery', () => { + describe('crypto.util – HMAC and webhook helpers', () => { + it('hmacSign produces deterministic HMAC-SHA256 hex', () => { + const secret = 'whsec_test'; + const payload = '{"event":"test"}'; + const expected = createHmac('sha256', secret).update(payload).digest('hex'); + expect(hmacSign(secret, payload)).toBe(expected); + }); + + it('generateWebhookSignature uses timestamp concatenated with body', () => { + const secret = 'whsec_abc123'; + const timestamp = '1700000000'; + const body = JSON.stringify({ event: 'wallet.created', data: { id: 'w-1' } }); + const expected = createHmac('sha256', secret).update(`${timestamp}${body}`).digest('hex'); + expect(generateWebhookSignature(secret, timestamp, body)).toBe(expected); + }); + + it('generateWebhookSignature differs for different timestamps', () => { + const secret = 's'; + const body = '{"a":1}'; + const sig1 = generateWebhookSignature(secret, '1000', body); + const sig2 = generateWebhookSignature(secret, '2000', body); + expect(sig1).not.toBe(sig2); + }); + + it('buildWebhookHeaders includes required X-Astroid headers', () => { + const headers = buildWebhookHeaders({ + signature: 'abc123', + timestamp: '1700000000', + deliveryId: 'delivery-123', + eventName: 'transaction.completed', + }); + expect(headers['x-astroid-signature']).toBe('abc123'); + expect(headers['x-astroid-timestamp']).toBe('1700000000'); + expect(headers['x-astroid-delivery']).toBe('delivery-123'); + expect(headers['x-astroid-event']).toBe('transaction.completed'); + // backward-compat alias + expect(headers['x-astroid-event-id']).toBe('delivery-123'); + }); + }); + + describe('WebhooksProcessor – header formatting & signature', () => { + let processor: WebhooksProcessor; + let fetchSpy: ReturnType; + + const SECRET = 'whsec_test-secret-key'; + const URL = 'https://example.com/webhook'; + const EVENT_ID = 'evt-123'; + + beforeEach(() => { + processor = new WebhooksProcessor({} as never); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends X-Astroid-Signature, X-Astroid-Delivery, X-Astroid-Event headers', async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve('OK') }); + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'budget.exceeded', + payload: { event: 'budget.exceeded', data: {} }, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + + await processor.process(job); + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers['x-astroid-signature']).toBeDefined(); + expect(opts.headers['x-astroid-signature']).toMatch(/^[0-9a-f]{64}$/); + expect(opts.headers['x-astroid-delivery']).toBe(EVENT_ID); + expect(opts.headers['x-astroid-event']).toBe('budget.exceeded'); + expect(opts.headers['x-astroid-timestamp']).toMatch(/^\d+$/); + }); + + it('signature is HMAC-SHA256 of timestamp + body', async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve('OK') }); + const payload = { event: 'policy.violated', data: { id: 'p1' } }; + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'policy.violated', + payload, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + + await processor.process(job); + const [, opts] = fetchSpy.mock.calls[0]; + const body: string = opts.body; + const timestamp: string = opts.headers['x-astroid-timestamp']; + const expected = createHmac('sha256', SECRET).update(`${timestamp}${body}`).digest('hex'); + expect(opts.headers['x-astroid-signature']).toBe(expected); + expect(body).toBe(JSON.stringify(payload)); + }); + + it('uses 5000ms timeout on fetch', async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve('OK') }); + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'wallet.created', + payload: {}, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + await processor.process(job); + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.signal).toBeInstanceOf(AbortSignal); + }); + + it('falls back to ConfigService secret when per-endpoint secret is empty', async () => { + const fallbackSecret = 'fallback-secret-123'; + const mockConfig = { + get: vi.fn((key: string) => (key === 'WEBHOOK_SECRET' ? fallbackSecret : undefined)), + } as unknown as import('@nestjs/config').ConfigService; + const processorWithFallback = new WebhooksProcessor({} as never, mockConfig); + fetchSpy.mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve('OK') }); + + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: '', + eventName: 'transaction.completed', + payload: { hello: 'world' }, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + + await processorWithFallback.process(job); + const [, opts] = fetchSpy.mock.calls[0]; + const body: string = opts.body; + const ts: string = opts.headers['x-astroid-timestamp']; + const expected = createHmac('sha256', fallbackSecret).update(`${ts}${body}`).digest('hex'); + expect(opts.headers['x-astroid-signature']).toBe(expected); + }); + + it('throws UnrecoverableError for non-transient 4xx and does not retry', async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }); + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'wallet.created', + payload: {}, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + await expect(processor.process(job)).rejects.toThrow(UnrecoverableError); + }); + + it('throws retriable error for transient 5xx to allow BullMQ exponential backoff', async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 503, text: () => Promise.resolve('Service Unavailable') }); + const job = { + id: 'job-1', + data: { + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'wallet.created', + payload: {}, + eventId: EVENT_ID, + }, + attemptsMade: 0, + } as unknown as Job; + await expect(processor.process(job)).rejects.toThrow('HTTP 503'); + try { + await processor.process(job); + } catch (e) { + expect(e).not.toBeInstanceOf(UnrecoverableError); + } + }); + }); + + describe('WebhookDeliveryService – BullMQ retry configuration', () => { + it('queues with 5 attempts and exponential backoff delay 2000', async () => { + const { WebhookDeliveryService } = await import('./services/webhook-delivery.service'); + const mockQueue = { add: vi.fn().mockResolvedValue({ id: 'job-1' }) } as unknown as import('bullmq').Queue; + const svc = new WebhookDeliveryService(mockQueue); + await svc.queueDelivery({ + webhookId: 'wh-1', + organizationId: 'org-1', + url: URL, + secret: SECRET, + eventName: 'transaction.completed', + payload: {}, + eventId: 'evt-1', + }); + const call = vi.mocked(mockQueue.add).mock.calls[0]; + expect(call[2]?.attempts).toBe(5); + expect(call[2]?.backoff).toEqual({ type: 'exponential', delay: 2000 }); + }); + }); + + const URL = 'https://example.com/webhook'; + const SECRET = 'whsec_test-secret-key'; +}); diff --git a/src/modules/webhooks/webhooks.processor.spec.ts b/src/modules/webhooks/webhooks.processor.spec.ts index 0e404ae..8784fc0 100644 --- a/src/modules/webhooks/webhooks.processor.spec.ts +++ b/src/modules/webhooks/webhooks.processor.spec.ts @@ -65,11 +65,16 @@ describe('WebhooksProcessor', () => { expect(options.headers['user-agent']).toBe('Astroid-Webhook-Bot/1.0'); expect(options.headers['x-astroid-event']).toBe('transaction.completed'); expect(options.headers['x-astroid-event-id']).toBe(EVENT_ID); + // New required headers per spec: X-Astroid-Delivery and timestamp + expect(options.headers['x-astroid-delivery']).toBe(EVENT_ID); + expect(options.headers['x-astroid-timestamp']).toBeDefined(); + expect(options.headers['x-astroid-timestamp']).toMatch(/^\d+$/); - // Verify HMAC-SHA256 signature + // Verify HMAC-SHA256 signature = HMAC(secret, timestamp + body) const body = options.body; + const timestamp = options.headers['x-astroid-timestamp']; const expectedSignature = createHmac('sha256', WEBHOOK_SECRET) - .update(body) + .update(`${timestamp}${body}`) .digest('hex'); expect(options.headers['x-astroid-signature']).toBe(expectedSignature); }); diff --git a/src/modules/webhooks/webhooks.processor.ts b/src/modules/webhooks/webhooks.processor.ts index 0d1a77a..e75b932 100644 --- a/src/modules/webhooks/webhooks.processor.ts +++ b/src/modules/webhooks/webhooks.processor.ts @@ -1,9 +1,10 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; import { Inject, Logger, Optional } from '@nestjs/common'; import { Job, UnrecoverableError } from 'bullmq'; import { Queues } from '../../queues/queues.constants'; import { WebhookJobData, WebhookJobResult } from './types/webhook-job.types'; -import { hmacSign } from '../../utils/crypto.util'; +import { generateWebhookSignature } from '../../utils/crypto.util'; import { PrismaService } from '../../database/prisma.service'; /** @@ -28,10 +29,23 @@ export class WebhooksProcessor extends WorkerHost { private readonly logger = new Logger(WebhooksProcessor.name); private static readonly NON_TRANSIENT_STATUSES = new Set([400, 401, 403, 404, 422]); - constructor(@Optional() @Inject(PrismaService) private readonly prisma?: PrismaService) { + constructor( + @Optional() @Inject(PrismaService) private readonly prisma?: PrismaService, + @Optional() private readonly configService?: ConfigService, + ) { super(); } + private resolveSecret(jobSecret?: string): string { + if (jobSecret) return jobSecret; + const fallback = + this.configService?.get('WEBHOOK_SECRET') ?? + this.configService?.get('STELLAR_WEBHOOK_SECRET') ?? + this.configService?.get('WEBHOOK_SIGNING_SECRET') ?? + ''; + return fallback; + } + async process(job: Job): Promise { const { webhookId, organizationId, url, secret, eventName, payload, eventId } = job.data; this.logger.debug(`Processing webhook ${webhookId} event ${eventName} attempt ${job.attemptsMade + 1}/5`); @@ -42,15 +56,19 @@ export class WebhooksProcessor extends WorkerHost { try { const body = JSON.stringify(payload); - const signature = hmacSign(secret, body); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const effectiveSecret = this.resolveSecret(secret); + const signature = generateWebhookSignature(effectiveSecret, timestamp, body); const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-astroid-signature': signature, - 'x-astroid-event-id': eventId, + 'x-astroid-timestamp': timestamp, + 'x-astroid-delivery': eventId, 'x-astroid-event': eventName, + 'x-astroid-event-id': eventId, 'user-agent': 'Astroid-Webhook-Bot/1.0', }, body, diff --git a/src/modules/webhooks/workers/webhook.worker.ts b/src/modules/webhooks/workers/webhook.worker.ts index f9e2d09..bd4fdda 100644 --- a/src/modules/webhooks/workers/webhook.worker.ts +++ b/src/modules/webhooks/workers/webhook.worker.ts @@ -1,9 +1,10 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { ConfigService } from '@nestjs/config'; import { Inject, Logger, Optional } from '@nestjs/common'; import { Job, UnrecoverableError } from 'bullmq'; import { Queues } from '../../../queues/queues.constants'; import { WebhookJobData, WebhookJobResult } from '../types/webhook-job.types'; -import { hmacSign } from '../../../utils/crypto.util'; +import { generateWebhookSignature } from '../../../utils/crypto.util'; import { PrismaService } from '../../../database/prisma.service'; /** @@ -28,10 +29,23 @@ export class WebhookWorker extends WorkerHost { */ private static readonly NON_TRANSIENT_STATUSES = new Set([400, 401, 403, 404, 422]); - constructor(@Optional() @Inject(PrismaService) private readonly prisma?: PrismaService) { + constructor( + @Optional() @Inject(PrismaService) private readonly prisma?: PrismaService, + @Optional() private readonly configService?: ConfigService, + ) { super(); } + private resolveSecret(jobSecret?: string): string { + if (jobSecret) return jobSecret; + const fallback = + this.configService?.get('WEBHOOK_SECRET') ?? + this.configService?.get('STELLAR_WEBHOOK_SECRET') ?? + this.configService?.get('WEBHOOK_SIGNING_SECRET') ?? + ''; + return fallback; + } + async process(job: Job): Promise { const { webhookId, organizationId, url, secret, eventName, payload, eventId } = job.data; @@ -44,15 +58,19 @@ export class WebhookWorker extends WorkerHost { try { const body = JSON.stringify(payload); - const signature = hmacSign(secret, body); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const effectiveSecret = this.resolveSecret(secret); + const signature = generateWebhookSignature(effectiveSecret, timestamp, body); const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-astroid-signature': signature, - 'x-astroid-event-id': eventId, + 'x-astroid-timestamp': timestamp, + 'x-astroid-delivery': eventId, 'x-astroid-event': eventName, + 'x-astroid-event-id': eventId, 'user-agent': 'Astroid-Webhook-Bot/1.0', }, body, diff --git a/src/utils/crypto.util.ts b/src/utils/crypto.util.ts index 9ca7ea6..d91eedb 100644 --- a/src/utils/crypto.util.ts +++ b/src/utils/crypto.util.ts @@ -20,6 +20,38 @@ export function hmacSign(secret: string, payload: string): string { return createHmac('sha256', secret).update(payload).digest('hex'); } +/** + * Generates a webhook HMAC-SHA256 signature per the Astroid spec. + * The signing payload is `timestamp + JSON-serialized body` (concatenated + * without delimiter) hashed with the tenant's webhook secret. + * A dot-delimited variant (`timestamp.body`) is also accepted by the + * verification guard; this helper uses the plain concatenation form to + * match the documented requirement. + */ +export function generateWebhookSignature(secret: string, timestamp: string, body: string): string { + return createHmac('sha256', secret).update(`${timestamp}${body}`).digest('hex'); +} + +/** + * Builds the standard set of webhook delivery headers. + * Includes X-Astroid-Signature, X-Astroid-Delivery, X-Astroid-Event and + * X-Astroid-Timestamp (plus X-Astroid-Event-Id for backward compatibility). + */ +export function buildWebhookHeaders(params: { + signature: string; + timestamp: string; + deliveryId: string; + eventName: string; +}): Record { + return { + 'x-astroid-signature': params.signature, + 'x-astroid-timestamp': params.timestamp, + 'x-astroid-delivery': params.deliveryId, + 'x-astroid-event': params.eventName, + 'x-astroid-event-id': params.deliveryId, + }; +} + /** Constant-time comparison of two signatures to prevent timing attacks. */ export function safeEqual(a: string, b: string): boolean { const bufA = Buffer.from(a); From dc06839c2492dbae7534b82ad26ca5aa62683031 Mon Sep 17 00:00:00 2001 From: Joyerin007 Date: Sun, 30 Aug 2026 21:17:30 +0000 Subject: [PATCH 2/2] chore: verify API key authentication guard and scope decorator implementation - Confirm ApiKey schema stores SHA-256 hashedKey - Verify ApiKeyGuard/RequireScopes with 401/403 handling - All auth guard tests passing (483 tests) Closes #