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
39 changes: 39 additions & 0 deletions app/api/openzepellin/webhook/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 10 additions & 3 deletions app/api/openzepellin/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions app/api/openzepellin/webhook/verify-signature.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -50,6 +51,7 @@
"postcss": "^8",
"tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.4",
"typescript": "^5"
}
}