diff --git a/apps/api/src/modules/webhooks/webhook.errors.ts b/apps/api/src/modules/webhooks/webhook.errors.ts index d93837d8..1c6cb761 100644 --- a/apps/api/src/modules/webhooks/webhook.errors.ts +++ b/apps/api/src/modules/webhooks/webhook.errors.ts @@ -155,3 +155,14 @@ export class WebhookInsufficientScopeError extends AppError { this.name = 'WebhookInsufficientScopeError'; } } + +export class WebhookSecretDecryptionError extends AppError { + constructor(subscriptionId: string) { + super({ + code: ErrorCodes.WEBHOOK_SECRET_DECRYPTION_FAILED, + message: `Failed to decrypt webhook secret for subscription ${subscriptionId}`, + statusCode: 500, + }); + this.name = 'WebhookSecretDecryptionError'; + } +} diff --git a/apps/api/src/modules/webhooks/webhook.service.ts b/apps/api/src/modules/webhooks/webhook.service.ts index c7a18d86..c19033ab 100644 --- a/apps/api/src/modules/webhooks/webhook.service.ts +++ b/apps/api/src/modules/webhooks/webhook.service.ts @@ -1,4 +1,4 @@ -import { randomUUID, createHmac, timingSafeEqual } from 'crypto'; +import { randomUUID, createHmac, timingSafeEqual, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; import { Queue } from 'bullmq'; @@ -26,6 +26,7 @@ import { WebhookDeliveryMaxRetriesExceededError, WebhookSignatureInvalidError, WebhookSignatureExpiredError, + WebhookSecretDecryptionError, } from './webhook.errors.js'; import { WEBHOOK_QUEUE_NAMES, @@ -40,6 +41,39 @@ import type { NewWebhookSubscriptionDb, } from '../../db/schema/webhooks.js'; +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; +const ENCRYPTION_IV_LENGTH = 16; +const ENCRYPTION_SALT_LENGTH = 32; + +function deriveEncryptionKey(encryptionKey: string, salt: Buffer): Buffer { + return scryptSync(encryptionKey, salt, 32); +} + +function encryptWebhookSecret(secret: string, encryptionKey: string): string { + const salt = randomBytes(ENCRYPTION_SALT_LENGTH); + const key = deriveEncryptionKey(encryptionKey, salt); + const iv = randomBytes(ENCRYPTION_IV_LENGTH); + const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${salt.toString('base64')}:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; +} + +function decryptWebhookSecret(encryptedData: string, encryptionKey: string): string { + const parts = encryptedData.split(':'); + if (parts.length !== 4) { + throw new Error('Invalid encrypted webhook secret format'); + } + const salt = Buffer.from(parts[0]!, 'base64'); + const iv = Buffer.from(parts[1]!, 'base64'); + const authTag = Buffer.from(parts[2]!, 'base64'); + const encrypted = Buffer.from(parts[3]!, 'base64'); + const key = deriveEncryptionKey(encryptionKey, salt); + const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); +} + interface SubscriptionUpdateData { name?: string; targetUrl?: string; @@ -95,7 +129,7 @@ export class WebhookService { } const generatedSecret = this.generateSecret(); - const secretHash = this.hashSecret(generatedSecret); + const secretHash = this.encryptSecretForStorage(generatedSecret); const subscription = await webhookRepo.createSubscription({ tenantId, @@ -213,7 +247,7 @@ export class WebhookService { } const newSecret = this.generateSecret(); - const newSecretHash = this.hashSecret(newSecret); + const newSecretHash = this.encryptSecretForStorage(newSecret); await webhookRepo.updateSubscription(tenantId, subscriptionId, { secretHash: newSecretHash, @@ -241,7 +275,18 @@ export class WebhookService { } const testPayload = this.createTestEventPayload(subscription.id, subscription.tenantId); - const signingSecret = this.secretCache.get(subscriptionId) ?? subscription.secretHash; + let signingSecret = this.secretCache.get(subscriptionId); + if (!signingSecret && subscription.secretHash) { + try { + signingSecret = this.decryptSecretFromStorage(subscription.secretHash); + this.secretCache.set(subscriptionId, signingSecret); + } catch { + throw new WebhookSecretDecryptionError(subscriptionId); + } + } + if (!signingSecret) { + throw new WebhookSecretDecryptionError(subscriptionId); + } const signature = this.generateSignature(testPayload, signingSecret); const signatureHeaders: Record = { @@ -333,7 +378,19 @@ export class WebhookService { throw new WebhookSubscriptionInvalidStatusError(subscription?.status ?? 'unknown'); } - const signingSecret = this.secretCache.get(subscriptionId) ?? subscription.secretHash; + let signingSecret = this.secretCache.get(subscriptionId); + if (!signingSecret && subscription.secretHash) { + // Decrypt the stored secret instead of using the hash as a signing key + try { + signingSecret = this.decryptSecretFromStorage(subscription.secretHash); + this.secretCache.set(subscriptionId, signingSecret); + } catch { + throw new WebhookSecretDecryptionError(subscriptionId); + } + } + if (!signingSecret) { + throw new WebhookSecretDecryptionError(subscriptionId); + } const signature = this.generateSignature(payload, signingSecret); const signatureHeaders: Record = { @@ -567,8 +624,18 @@ export class WebhookService { return randomUUID() + randomUUID(); } - private hashSecret(secret: string): string { - return createHmac('sha256', secret).update(secret).digest('hex'); + private encryptSecretForStorage(secret: string): string { + const encryptionKey = this.getEncryptionKey(); + return encryptWebhookSecret(secret, encryptionKey); + } + + private decryptSecretFromStorage(encrypted: string): string { + const encryptionKey = this.getEncryptionKey(); + return decryptWebhookSecret(encrypted, encryptionKey); + } + + private getEncryptionKey(): string { + return process.env.JWT_PRIVATE_KEY_ENCRYPTION_KEY || 'dev-webhook-encryption-key'; } generateSignature(payload: Record, secret: string): string { diff --git a/packages/shared/src/constants/error-codes.ts b/packages/shared/src/constants/error-codes.ts index 73e35fe9..ee212f1e 100644 --- a/packages/shared/src/constants/error-codes.ts +++ b/packages/shared/src/constants/error-codes.ts @@ -199,6 +199,7 @@ export const ErrorCodes = { WEBHOOK_RATE_LIMIT_EXCEEDED: 'WEBHOOK_RATE_LIMIT_EXCEEDED', WEBHOOK_UNAUTHORIZED: 'WEBHOOK_UNAUTHORIZED', WEBHOOK_INSUFFICIENT_SCOPE: 'WEBHOOK_INSUFFICIENT_SCOPE', + WEBHOOK_SECRET_DECRYPTION_FAILED: 'WEBHOOK_SECRET_DECRYPTION_FAILED', } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; @@ -1130,6 +1131,11 @@ export const errorCodeMetadata: Record = { retryable: false, messageKey: 'errors.webhook.insufficientScope', }, + [ErrorCodes.WEBHOOK_SECRET_DECRYPTION_FAILED]: { + category: ErrorCodeCategory.SERVER, + retryable: false, + messageKey: 'errors.webhook.secretDecryptionFailed', + }, }; export const allErrorCodes: readonly ErrorCode[] = Object.keys(ErrorCodes) as ErrorCode[];