Skip to content
Open
6 changes: 5 additions & 1 deletion app/api/webhooks/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
26 changes: 23 additions & 3 deletions lib/models/webhookModel.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -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 || "",
Expand All @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions lib/webhook/crypto.js
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 7 additions & 1 deletion lib/webhook/dispatcher.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getActiveWebhooksByEvent,
getWebhookSecretForSigning,
} from "@/lib/models/webhookModel";
import {
createDeliveryLog,
Expand Down Expand Up @@ -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,
Expand Down
Loading