diff --git a/.env.example b/.env.example index 9bf777b..48d80f5 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ SUPABASE_SECRET_KEY= CIRCLE_API_KEY= # 32-byte hex (64 chars). Register once with Circle before use CIRCLE_ENTITY_SECRET= +# Optional extra shared secret. When set, Circle webhook requests must include +# Authorization: Bearer in addition to Circle signatures. +CIRCLE_WEBHOOK_SECRET= # Circle blockchain identifier for Arc Testnet CIRCLE_BLOCKCHAIN=ARC-TESTNET diff --git a/README.md b/README.md index e8dbe45..3d42d5a 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,14 @@ cp .env.example .env.local ``` - `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` / -`SUPABASE_SECRET_KEY` - from `npm run db:status`. + `SUPABASE_SECRET_KEY` - from `npm run db:status`. - `CIRCLE_API_KEY` - from the Circle console. - `CIRCLE_ENTITY_SECRET` - your 32-byte hex entity secret. Must be registered -with Circle once before use. + with Circle once before use. +- `CIRCLE_WEBHOOK_SECRET` - optional extra shared secret for the Circle webhook + endpoint. When set, webhook requests must include + `Authorization: Bearer ` in addition to Circle's + `X-Circle-Signature` and `X-Circle-Key-Id` headers. - `CIRCLE_BLOCKCHAIN` - Circle blockchain identifier (default `ARC-TESTNET`). - `KIT_KEY` - Circle App Kit key. - `NEXT_PUBLIC_ARC_CHAIN` - App Kit chain identifier (default `Arc_Testnet`). diff --git a/src/app/api/webhooks/circle/route.ts b/src/app/api/webhooks/circle/route.ts index 0164932..5ec9cbb 100644 --- a/src/app/api/webhooks/circle/route.ts +++ b/src/app/api/webhooks/circle/route.ts @@ -17,11 +17,14 @@ */ import { z } from "zod"; +import crypto from "crypto"; import { getFxBalances } from "@/lib/circle/wallets"; import { serverEnv } from "@/lib/config"; import { createAdminClient } from "@/lib/supabase/admin"; +const publicKeyCache = new Map(); + // Circle sends a HEAD request to verify the endpoint is reachable. export function HEAD() { return new Response(null, { status: 200 }); @@ -56,6 +59,17 @@ export async function POST(request: Request) { return new Response("Bad Request", { status: 400 }); } + const signature = request.headers.get("x-circle-signature"); + const keyId = request.headers.get("x-circle-key-id"); + if (!signature || !keyId) { + return new Response("Missing Circle signature headers", { status: 400 }); + } + + const isVerified = await verifyCircleSignature(rawBody, signature, keyId, env.CIRCLE_API_KEY); + if (!isVerified) { + return new Response("Invalid Circle signature", { status: 403 }); + } + let body: unknown; try { body = JSON.parse(rawBody); @@ -136,3 +150,49 @@ async function handleInboundComplete(walletId?: string, destinationAddress?: str console.error("[webhook/circle] balance update failed", err); } } + +async function verifyCircleSignature( + body: string, + signature: string, + keyId: string, + apiKey: string, +): Promise { + try { + const publicKey = await getCirclePublicKey(keyId, apiKey); + const verifier = crypto.createVerify("SHA256"); + verifier.update(body, "utf8"); + verifier.end(); + return verifier.verify(publicKey, Buffer.from(signature, "base64")); + } catch (err) { + console.warn("[webhook/circle] signature verification failed", err); + return false; + } +} + +async function getCirclePublicKey(keyId: string, apiKey: string): Promise { + const cached = publicKeyCache.get(keyId); + if (cached) return cached; + + const response = await fetch(`https://api.circle.com/v2/notifications/publicKey/${keyId}`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!response.ok) { + throw new Error(`failed to fetch Circle public key: ${response.status}`); + } + + const data: unknown = await response.json(); + const publicKey = z + .object({ data: z.object({ publicKey: z.string().min(1) }) }) + .parse(data).data.publicKey; + const pem = [ + "-----BEGIN PUBLIC KEY-----", + ...(publicKey.match(/.{1,64}/g) ?? []), + "-----END PUBLIC KEY-----", + ].join("\n"); + + publicKeyCache.set(keyId, pem); + return pem; +}