diff --git a/app/api/openzepellin/webhook/route.test.ts b/app/api/openzepellin/webhook/route.test.ts new file mode 100644 index 0000000..cb3a0d0 --- /dev/null +++ b/app/api/openzepellin/webhook/route.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { checkSignature } from "./verify-signature.ts"; + +const SECRET = "test-signing-key"; + +function sign(body: Buffer, secret = SECRET): string { + return createHmac("sha256", secret).update(body).digest("base64"); +} + +describe("checkSignature", () => { + it("accepts a valid signature", () => { + const body = Buffer.from('{"event":"test"}'); + assert.equal(checkSignature(body, SECRET, sign(body)), true); + }); + + it("rejects a signature produced with the wrong secret", () => { + const body = Buffer.from('{"event":"test"}'); + assert.equal(checkSignature(body, SECRET, sign(body, "wrong-secret")), false); + }); + + it("rejects a signature when the body has been tampered", () => { + const body = Buffer.from('{"event":"test"}'); + const tamperedBody = Buffer.from('{"event":"tampered"}'); + assert.equal(checkSignature(tamperedBody, SECRET, sign(body)), false); + }); + + it("rejects a hex-encoded signature", () => { + const body = Buffer.from('{"event":"test"}'); + const hexSig = createHmac("sha256", SECRET).update(body).digest("hex"); + assert.equal(checkSignature(body, SECRET, hexSig), false); + }); + + it("rejects an empty signature header", () => { + const body = Buffer.from('{"event":"test"}'); + assert.equal(checkSignature(body, SECRET, ""), false); + }); +}); diff --git a/app/api/openzepellin/webhook/route.ts b/app/api/openzepellin/webhook/route.ts index d69dda2..8404b7a 100644 --- a/app/api/openzepellin/webhook/route.ts +++ b/app/api/openzepellin/webhook/route.ts @@ -18,6 +18,7 @@ import { NextRequest, NextResponse } from "next/server"; import { supabaseAdminClient } from "@/lib/supabase/admin-client"; +import { checkSignature } from "./verify-signature"; // This interface is a simplified version of the relayer's payload. interface RelayerNotificationPayload { @@ -28,9 +29,15 @@ interface RelayerNotificationPayload { export async function POST(req: NextRequest) { try { - // In production, you would verify the signature from the relayer - // using the WEBHOOK_SIGNING_KEY you configured. - const body = await req.json(); + const rawBody = Buffer.from(await req.arrayBuffer()); + const sigHeader = req.headers.get("x-signature") ?? ""; + const signingKey = process.env.WEBHOOK_SIGNING_KEY ?? ""; + + if (!checkSignature(rawBody, signingKey, sigHeader)) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + const body = JSON.parse(rawBody.toString("utf8")); const notification = body.payload as RelayerNotificationPayload; console.log("Received notification from OpenZeppelin Relayer:", notification); diff --git a/app/api/openzepellin/webhook/verify-signature.ts b/app/api/openzepellin/webhook/verify-signature.ts new file mode 100644 index 0000000..8037964 --- /dev/null +++ b/app/api/openzepellin/webhook/verify-signature.ts @@ -0,0 +1,34 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Verifies an OpenZeppelin Relayer HMAC-SHA256 webhook signature. + * + * @param rawBody - raw request body bytes + * @param signingKey - WEBHOOK_SIGNING_KEY configured in the relayer + * @param signatureHeader - value of the X-Signature request header (base64) + * @returns true only when the signature is valid + */ +export function checkSignature( + rawBody: Buffer, + signingKey: string, + signatureHeader: string, +): boolean { + if (!signatureHeader) return false; + + // Reject non-base64 encodings (e.g. hex strings) + if (!/^[A-Za-z0-9+/]+=*$/.test(signatureHeader)) return false; + + const expected = createHmac("sha256", signingKey).update(rawBody).digest(); + + let actual: Buffer; + try { + actual = Buffer.from(signatureHeader, "base64"); + } catch { + return false; + } + + // timingSafeEqual requires identical lengths; check explicitly first + if (actual.length !== expected.length) return false; + + return timingSafeEqual(expected, actual); +} diff --git a/package.json b/package.json index d5c401d..2cef281 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "node --import tsx --test app/api/openzepellin/webhook/route.test.ts" }, "dependencies": { "@circle-fin/developer-controlled-wallets": "^10.0.1", @@ -50,6 +51,7 @@ "postcss": "^8", "tailwindcss": "^4.1.13", "tailwindcss-animate": "^1.0.7", + "tsx": "^4.19.4", "typescript": "^5" } }