|
17 | 17 | */ |
18 | 18 |
|
19 | 19 | import { z } from "zod"; |
| 20 | +import crypto from "crypto"; |
20 | 21 |
|
21 | 22 | import { getFxBalances } from "@/lib/circle/wallets"; |
22 | 23 | import { serverEnv } from "@/lib/config"; |
23 | 24 | import { createAdminClient } from "@/lib/supabase/admin"; |
24 | 25 |
|
| 26 | +const publicKeyCache = new Map<string, string>(); |
| 27 | + |
25 | 28 | // Circle sends a HEAD request to verify the endpoint is reachable. |
26 | 29 | export function HEAD() { |
27 | 30 | return new Response(null, { status: 200 }); |
@@ -56,6 +59,17 @@ export async function POST(request: Request) { |
56 | 59 | return new Response("Bad Request", { status: 400 }); |
57 | 60 | } |
58 | 61 |
|
| 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 | + |
59 | 73 | let body: unknown; |
60 | 74 | try { |
61 | 75 | body = JSON.parse(rawBody); |
@@ -136,3 +150,49 @@ async function handleInboundComplete(walletId?: string, destinationAddress?: str |
136 | 150 | console.error("[webhook/circle] balance update failed", err); |
137 | 151 | } |
138 | 152 | } |
| 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