diff --git a/app/api/webhooks/route.js b/app/api/webhooks/route.js index be611c8b1..bcd9fdd08 100644 --- a/app/api/webhooks/route.js +++ b/app/api/webhooks/route.js @@ -50,7 +50,11 @@ export const POST = withErrorHandler(async (request) => { createdBy: decodedToken.uid, }); - return jsonSuccess({ webhook }, 201); + return jsonSuccess({ + webhook, + secret, + message: "Save this secret securely. It will not be shown again.", + }, 201); }); export const GET = withErrorHandler(async (request) => { diff --git a/lib/models/webhookModel.js b/lib/models/webhookModel.js index 0120d0dd7..6c61cf88f 100644 --- a/lib/models/webhookModel.js +++ b/lib/models/webhookModel.js @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { connectDb } from "@/lib/mongodb"; +import { encryptSecret, decryptSecret, hashSecret } from "@/lib/webhook/crypto"; const COLLECTION = "webhooks"; let indexesEnsured = false; @@ -15,7 +16,8 @@ async function ensureIndexes(db) { export function serializeWebhook(doc) { if (!doc) return null; - return { ...doc, _id: doc._id?.toString?.() || doc._id }; + const { _id, secret, secretHash, ...rest } = doc; + return { ...rest, _id: _id?.toString?.() || _id }; } export async function createWebhook(data) { @@ -25,7 +27,8 @@ export async function createWebhook(data) { const doc = { webhookId: randomUUID(), url: data.url, - secret: data.secret, + encryptedSecret: encryptSecret(data.secret), + secretHash: hashSecret(data.secret), events: data.events, status: data.status || "active", description: data.description || "", @@ -43,13 +46,30 @@ export async function getWebhookById(webhookId) { return serializeWebhook(doc); } +export async function getWebhookSecretForSigning(webhookId) { + const db = await connectDb(); + const doc = await db.collection(COLLECTION).findOne({ webhookId }); + if (!doc || !doc.encryptedSecret) return null; + try { + return decryptSecret(doc.encryptedSecret); + } catch { + return null; + } +} + export async function updateWebhook(webhookId, updates) { const db = await connectDb(); - const allowed = ["url", "secret", "events", "status", "description"]; + const allowed = ["url", "events", "status", "description"]; const setFields = {}; for (const key of allowed) { if (updates[key] !== undefined) setFields[key] = updates[key]; } + + if (updates.secret !== undefined) { + setFields.encryptedSecret = encryptSecret(updates.secret); + setFields.secretHash = hashSecret(updates.secret); + } + setFields.updatedAt = new Date().toISOString(); await db.collection(COLLECTION).updateOne({ webhookId }, { $set: setFields }); return getWebhookById(webhookId); diff --git a/lib/webhook/crypto.js b/lib/webhook/crypto.js new file mode 100644 index 000000000..944bdd403 --- /dev/null +++ b/lib/webhook/crypto.js @@ -0,0 +1,64 @@ +import { createHmac, randomBytes, createCipheriv, createDecipheriv, scryptSync } from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +function getEncryptionKey() { + const secret = process.env.WEBHOOK_ENCRYPTION_KEY || process.env.CRON_SECRET; + if (!secret) { + throw new Error("WEBHOOK_ENCRYPTION_KEY or CRON_SECRET must be set"); + } + return scryptSync(secret, "webhook-salt", KEY_LENGTH); +} + +export function encryptSecret(plaintext) { + const key = getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; +} + +export function decryptSecret(encryptedValue) { + const key = getEncryptionKey(); + const parts = encryptedValue.split(":"); + + if (parts.length !== 3) { + throw new Error("Invalid encrypted value format"); + } + + const iv = Buffer.from(parts[0], "hex"); + const authTag = Buffer.from(parts[1], "hex"); + const encrypted = parts[2]; + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; +} + +export function hashSecret(secret) { + const pepper = process.env.WEBHOOK_SECRET_PEPPER || process.env.CRON_SECRET || "default-pepper"; + return createHmac("sha256", pepper).update(secret).digest("hex"); +} + +export function verifySecretHash(secret, hash) { + const computed = hashSecret(secret); + if (computed.length !== hash.length) return false; + + let result = 0; + for (let i = 0; i < computed.length; i++) { + result |= computed.charCodeAt(i) ^ hash.charCodeAt(i); + } + return result === 0; +} diff --git a/lib/webhook/dispatcher.js b/lib/webhook/dispatcher.js index 544b6e095..ef3a5ccf5 100644 --- a/lib/webhook/dispatcher.js +++ b/lib/webhook/dispatcher.js @@ -1,5 +1,6 @@ import { getActiveWebhooksByEvent, + getWebhookSecretForSigning, } from "@/lib/models/webhookModel"; import { createDeliveryLog, @@ -60,7 +61,12 @@ export async function emitWebhookEvent(eventType, data) { }; for (const webhook of webhooks) { - const signature = signPayload(payload, webhook.secret); + const signingSecret = await getWebhookSecretForSigning(webhook.webhookId); + if (!signingSecret) { + console.error(`[webhook] Failed to decrypt secret for webhook ${webhook.webhookId}, skipping`); + continue; + } + const signature = signPayload(payload, signingSecret); const deliveryLog = await createDeliveryLog({ webhookId: webhook.webhookId, eventType,