Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/api/src/modules/webhooks/webhook.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
81 changes: 74 additions & 7 deletions apps/api/src/modules/webhooks/webhook.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID, createHmac, timingSafeEqual } from 'crypto';
import { randomUUID, createHmac, timingSafeEqual, createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';

import { Queue } from 'bullmq';

Expand Down Expand Up @@ -26,6 +26,7 @@ import {
WebhookDeliveryMaxRetriesExceededError,
WebhookSignatureInvalidError,
WebhookSignatureExpiredError,
WebhookSecretDecryptionError,
} from './webhook.errors.js';
import {
WEBHOOK_QUEUE_NAMES,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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<string, unknown>, secret: string): string {
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/constants/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -1130,6 +1131,11 @@ export const errorCodeMetadata: Record<ErrorCode, ErrorCodeMetadata> = {
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[];
Expand Down