Skip to content

Commit 24bfc35

Browse files
committed
fix: verify Circle webhook signatures
1 parent 779eb1a commit 24bfc35

3 files changed

Lines changed: 69 additions & 2 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ SUPABASE_SECRET_KEY=
99
CIRCLE_API_KEY=
1010
# 32-byte hex (64 chars). Register once with Circle before use
1111
CIRCLE_ENTITY_SECRET=
12+
# Optional extra shared secret. When set, Circle webhook requests must include
13+
# Authorization: Bearer <CIRCLE_WEBHOOK_SECRET> in addition to Circle signatures.
14+
CIRCLE_WEBHOOK_SECRET=
1215
# Circle blockchain identifier for Arc Testnet
1316
CIRCLE_BLOCKCHAIN=ARC-TESTNET
1417

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ cp .env.example .env.local
3838
```
3939

4040
- `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` /
41-
`SUPABASE_SECRET_KEY` - from `npm run db:status`.
41+
`SUPABASE_SECRET_KEY` - from `npm run db:status`.
4242
- `CIRCLE_API_KEY` - from the Circle console.
4343
- `CIRCLE_ENTITY_SECRET` - your 32-byte hex entity secret. Must be registered
44-
with Circle once before use.
44+
with Circle once before use.
45+
- `CIRCLE_WEBHOOK_SECRET` - optional extra shared secret for the Circle webhook
46+
endpoint. When set, webhook requests must include
47+
`Authorization: Bearer <CIRCLE_WEBHOOK_SECRET>` in addition to Circle's
48+
`X-Circle-Signature` and `X-Circle-Key-Id` headers.
4549
- `CIRCLE_BLOCKCHAIN` - Circle blockchain identifier (default `ARC-TESTNET`).
4650
- `KIT_KEY` - Circle App Kit key.
4751
- `NEXT_PUBLIC_ARC_CHAIN` - App Kit chain identifier (default `Arc_Testnet`).

src/app/api/webhooks/circle/route.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717
*/
1818

1919
import { z } from "zod";
20+
import crypto from "crypto";
2021

2122
import { getFxBalances } from "@/lib/circle/wallets";
2223
import { serverEnv } from "@/lib/config";
2324
import { createAdminClient } from "@/lib/supabase/admin";
2425

26+
const publicKeyCache = new Map<string, string>();
27+
2528
// Circle sends a HEAD request to verify the endpoint is reachable.
2629
export function HEAD() {
2730
return new Response(null, { status: 200 });
@@ -56,6 +59,17 @@ export async function POST(request: Request) {
5659
return new Response("Bad Request", { status: 400 });
5760
}
5861

62+
const signature = request.headers.get("x-circle-signature");
63+
const keyId = request.headers.get("x-circle-key-id");
64+
if (!signature || !keyId) {
65+
return new Response("Missing Circle signature headers", { status: 400 });
66+
}
67+
68+
const isVerified = await verifyCircleSignature(rawBody, signature, keyId, env.CIRCLE_API_KEY);
69+
if (!isVerified) {
70+
return new Response("Invalid Circle signature", { status: 403 });
71+
}
72+
5973
let body: unknown;
6074
try {
6175
body = JSON.parse(rawBody);
@@ -136,3 +150,49 @@ async function handleInboundComplete(walletId?: string, destinationAddress?: str
136150
console.error("[webhook/circle] balance update failed", err);
137151
}
138152
}
153+
154+
async function verifyCircleSignature(
155+
body: string,
156+
signature: string,
157+
keyId: string,
158+
apiKey: string,
159+
): Promise<boolean> {
160+
try {
161+
const publicKey = await getCirclePublicKey(keyId, apiKey);
162+
const verifier = crypto.createVerify("SHA256");
163+
verifier.update(body, "utf8");
164+
verifier.end();
165+
return verifier.verify(publicKey, Buffer.from(signature, "base64"));
166+
} catch (err) {
167+
console.warn("[webhook/circle] signature verification failed", err);
168+
return false;
169+
}
170+
}
171+
172+
async function getCirclePublicKey(keyId: string, apiKey: string): Promise<string> {
173+
const cached = publicKeyCache.get(keyId);
174+
if (cached) return cached;
175+
176+
const response = await fetch(`https://api.circle.com/v2/notifications/publicKey/${keyId}`, {
177+
headers: {
178+
Accept: "application/json",
179+
Authorization: `Bearer ${apiKey}`,
180+
},
181+
});
182+
if (!response.ok) {
183+
throw new Error(`failed to fetch Circle public key: ${response.status}`);
184+
}
185+
186+
const data: unknown = await response.json();
187+
const publicKey = z
188+
.object({ data: z.object({ publicKey: z.string().min(1) }) })
189+
.parse(data).data.publicKey;
190+
const pem = [
191+
"-----BEGIN PUBLIC KEY-----",
192+
...(publicKey.match(/.{1,64}/g) ?? []),
193+
"-----END PUBLIC KEY-----",
194+
].join("\n");
195+
196+
publicKeyCache.set(keyId, pem);
197+
return pem;
198+
}

0 commit comments

Comments
 (0)