Skip to content
Merged
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
20 changes: 12 additions & 8 deletions docs/bridge-integration/WEBHOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@ Flash receives real-time updates from Bridge.xyz via webhooks. These webhooks ar

## Webhook Endpoint

The webhook server listens on the configured port (default: `3005`) and expects POST requests at the following endpoints:
The webhook server listens on the configured port (default: `4009`) and expects POST requests at the following endpoints:

- `POST /bridge/webhooks/kyc`
- `POST /bridge/webhooks/deposit`
- `POST /bridge/webhooks/transfer`
- `POST /kyc`
- `POST /deposit`
- `POST /transfer`
- `POST /external-account`

## Signature Verification

All incoming webhooks from Bridge.xyz are signed using asymmetric RSA-SHA256. Flash verifies these signatures using the public keys provided by Bridge.xyz.

### Verification Process

1. Retrieve the signature from the `Bridge-Signature` header.
2. Retrieve the timestamp from the `Bridge-Timestamp` header.
1. Retrieve the signature header from `X-Webhook-Signature`.
2. Parse the timestamp and signature from the header format: `t=<timestamp_ms>,v0=<base64_signature>`.
3. Verify that the timestamp is within the allowed skew (default: 5 minutes) to prevent replay attacks.
4. Construct the signed payload by concatenating the timestamp and the raw request body: `timestamp + "." + rawBody`.
5. Verify the signature against the signed payload using the appropriate public key (KYC, Deposit, or Transfer).
4. Construct the signed payload by concatenating the timestamp and the exact raw request body: `timestamp + "." + rawBody`.
5. Hash the signed payload with SHA-256.
6. Verify the Base64 `v0` signature against that digest using RSA-SHA256 and the appropriate Bridge public key (KYC, Deposit, Transfer, or External Account).

Flash must verify against the raw body captured before JSON parsing. Re-serializing the parsed JSON body changes the signed bytes and must fail signature verification.

## Event Types

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
jest.mock("@config", () => ({
BridgeConfig: {
webhook: {
publicKeys: {
kyc: "",
deposit: "",
transfer: "",
external_account: "",
},
timestampSkewMs: 5 * 60 * 1000,
},
},
}))

jest.mock("@services/logger", () => ({
baseLogger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}))

import crypto from "crypto"

import { NextFunction, Request, Response } from "express"

import { verifyBridgeSignature } from "@services/bridge/webhook-server/middleware/verify-signature"

type RawBodyRequest = Request & { rawBody?: string }

const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
})

const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString()

const RAW_BODY = JSON.stringify({
api_version: "v0",
event_id: "wh_signature_fixture",
event_category: "customer",
event_type: "kyc.approved",
event_object_id: "cust_signature_fixture",
})

const makeRes = () => {
const res = { status: jest.fn(), json: jest.fn() } as unknown as Response
;(res.status as jest.Mock).mockReturnValue(res)
;(res.json as jest.Mock).mockReturnValue(res)
return res
}

const makeReq = (signature: string, rawBody = RAW_BODY) =>
({
headers: { "x-webhook-signature": signature },
rawBody,
}) as RawBodyRequest

const signBridgeDigestFixture = (timestamp: string, rawBody = RAW_BODY) => {
const signedPayload = `${timestamp}.${rawBody}`
const digest = crypto.createHash("sha256").update(signedPayload).digest()
const signer = crypto.createSign("RSA-SHA256")
signer.update(digest)
return signer.sign(privateKey, "base64")
}

const signRawPayloadDirectly = (timestamp: string, rawBody = RAW_BODY) => {
const signer = crypto.createSign("RSA-SHA256")
signer.update(`${timestamp}.${rawBody}`)
return signer.sign(privateKey, "base64")
}

const signatureHeader = (timestamp: string, signature: string) =>
`t=${timestamp},v0=${signature}`

describe("verifyBridgeSignature", () => {
beforeAll(() => {
const { BridgeConfig } = jest.requireMock("@config")
BridgeConfig.webhook.publicKeys.kyc = publicKeyPem
})

beforeEach(() => {
jest.clearAllMocks()
})

it("accepts the Bridge documented digest signature over timestamp and exact raw body", () => {
const timestamp = Date.now().toString()
const signature = signBridgeDigestFixture(timestamp)
const req = makeReq(signatureHeader(timestamp, signature))
const res = makeRes()
const next = jest.fn() as NextFunction

verifyBridgeSignature("kyc")(req, res, next)

expect(next).toHaveBeenCalledTimes(1)
expect(res.status as jest.Mock).not.toHaveBeenCalled()
})

it("rejects signatures created over the raw timestamp/body payload directly", () => {
const timestamp = Date.now().toString()
const signature = signRawPayloadDirectly(timestamp)
const req = makeReq(signatureHeader(timestamp, signature))
const res = makeRes()
const next = jest.fn() as NextFunction

verifyBridgeSignature("kyc")(req, res, next)

expect(next).not.toHaveBeenCalled()
expect(res.status as jest.Mock).toHaveBeenCalledWith(401)
expect(res.json as jest.Mock).toHaveBeenCalledWith({ error: "Invalid signature" })
})

it("rejects digest signatures generated from a reserialized body instead of the captured raw body", () => {
const timestamp = Date.now().toString()
const reserializedBody = JSON.stringify(JSON.parse(RAW_BODY), null, 2)
const signature = signBridgeDigestFixture(timestamp, reserializedBody)
const req = makeReq(signatureHeader(timestamp, signature), RAW_BODY)
const res = makeRes()
const next = jest.fn() as NextFunction

verifyBridgeSignature("kyc")(req, res, next)

expect(next).not.toHaveBeenCalled()
expect(res.status as jest.Mock).toHaveBeenCalledWith(401)
expect(res.json as jest.Mock).toHaveBeenCalledWith({ error: "Invalid signature" })
})
})