Skip to content

Commit 8ceead1

Browse files
committed
fix: implement HMAC-SHA256 signature verification for OpenZeppelin webhook
- Read X-Signature header and base64-decode the signature (matches OpenZeppelin Relayer spec) - Use timingSafeEqual with explicit length check on raw HMAC bytes - Extract verification logic into pure checkSignature() module for testability - Add 5 unit tests covering valid sig, wrong secret, tampered body, hex format, empty header - Add npm test script using node:test with tsx loader
1 parent fe61256 commit 8ceead1

4 files changed

Lines changed: 86 additions & 4 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createHmac } from "node:crypto";
4+
import { checkSignature } from "./verify-signature.ts";
5+
6+
const SECRET = "test-signing-key";
7+
8+
function sign(body: Buffer, secret = SECRET): string {
9+
return createHmac("sha256", secret).update(body).digest("base64");
10+
}
11+
12+
describe("checkSignature", () => {
13+
it("accepts a valid signature", () => {
14+
const body = Buffer.from('{"event":"test"}');
15+
assert.equal(checkSignature(body, SECRET, sign(body)), true);
16+
});
17+
18+
it("rejects a signature produced with the wrong secret", () => {
19+
const body = Buffer.from('{"event":"test"}');
20+
assert.equal(checkSignature(body, SECRET, sign(body, "wrong-secret")), false);
21+
});
22+
23+
it("rejects a signature when the body has been tampered", () => {
24+
const body = Buffer.from('{"event":"test"}');
25+
const tamperedBody = Buffer.from('{"event":"tampered"}');
26+
assert.equal(checkSignature(tamperedBody, SECRET, sign(body)), false);
27+
});
28+
29+
it("rejects a hex-encoded signature", () => {
30+
const body = Buffer.from('{"event":"test"}');
31+
const hexSig = createHmac("sha256", SECRET).update(body).digest("hex");
32+
assert.equal(checkSignature(body, SECRET, hexSig), false);
33+
});
34+
35+
it("rejects an empty signature header", () => {
36+
const body = Buffer.from('{"event":"test"}');
37+
assert.equal(checkSignature(body, SECRET, ""), false);
38+
});
39+
});

app/api/openzepellin/webhook/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { NextRequest, NextResponse } from "next/server";
2020
import { supabaseAdminClient } from "@/lib/supabase/admin-client";
21+
import { checkSignature } from "./verify-signature";
2122

2223
// This interface is a simplified version of the relayer's payload.
2324
interface RelayerNotificationPayload {
@@ -28,9 +29,15 @@ interface RelayerNotificationPayload {
2829

2930
export async function POST(req: NextRequest) {
3031
try {
31-
// In production, you would verify the signature from the relayer
32-
// using the WEBHOOK_SIGNING_KEY you configured.
33-
const body = await req.json();
32+
const rawBody = Buffer.from(await req.arrayBuffer());
33+
const sigHeader = req.headers.get("x-signature") ?? "";
34+
const signingKey = process.env.WEBHOOK_SIGNING_KEY ?? "";
35+
36+
if (!checkSignature(rawBody, signingKey, sigHeader)) {
37+
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
38+
}
39+
40+
const body = JSON.parse(rawBody.toString("utf8"));
3441
const notification = body.payload as RelayerNotificationPayload;
3542

3643
console.log("Received notification from OpenZeppelin Relayer:", notification);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createHmac, timingSafeEqual } from "node:crypto";
2+
3+
/**
4+
* Verifies an OpenZeppelin Relayer HMAC-SHA256 webhook signature.
5+
*
6+
* @param rawBody - raw request body bytes
7+
* @param signingKey - WEBHOOK_SIGNING_KEY configured in the relayer
8+
* @param signatureHeader - value of the X-Signature request header (base64)
9+
* @returns true only when the signature is valid
10+
*/
11+
export function checkSignature(
12+
rawBody: Buffer,
13+
signingKey: string,
14+
signatureHeader: string,
15+
): boolean {
16+
if (!signatureHeader) return false;
17+
18+
// Reject non-base64 encodings (e.g. hex strings)
19+
if (!/^[A-Za-z0-9+/]+=*$/.test(signatureHeader)) return false;
20+
21+
const expected = createHmac("sha256", signingKey).update(rawBody).digest();
22+
23+
let actual: Buffer;
24+
try {
25+
actual = Buffer.from(signatureHeader, "base64");
26+
} catch {
27+
return false;
28+
}
29+
30+
// timingSafeEqual requires identical lengths; check explicitly first
31+
if (actual.length !== expected.length) return false;
32+
33+
return timingSafeEqual(expected, actual);
34+
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "next dev --turbopack",
88
"build": "next build",
99
"start": "next start",
10-
"lint": "eslint ."
10+
"lint": "eslint .",
11+
"test": "node --import tsx --test app/api/openzepellin/webhook/route.test.ts"
1112
},
1213
"dependencies": {
1314
"@circle-fin/developer-controlled-wallets": "^10.0.1",
@@ -50,6 +51,7 @@
5051
"postcss": "^8",
5152
"tailwindcss": "^4.1.13",
5253
"tailwindcss-animate": "^1.0.7",
54+
"tsx": "^4.19.4",
5355
"typescript": "^5"
5456
}
5557
}

0 commit comments

Comments
 (0)