|
/** |
|
* Paycrest aggregator webhook HMAC verification (same rules as aggregator/utils). |
|
* |
|
* Export `verifyPaycrestWebhookSignature` / `getPaycrestSignatureHeader` into your |
|
* ramp processor (e.g. PaycrestProcessor.verifySignature) — use exact raw body bytes |
|
* and plaintext PAYCREST_SECRET (API secret). Do not use Buffer.from(..., "hex") |
|
* on the header; compare hex strings as UTF-8 (Go hmac.Equal on ascii hex). |
|
* |
|
* Legacy aggregator quirk: webhook body was json.Encoder.Encode (trailing newline) while |
|
* HMAC used json.Marshal (no trailing newline). Verification tries the raw body then the |
|
* same bytes without a single trailing CRLF/LF so both shapes work. |
|
* |
|
* Run self-test: npm run test-webhook |
|
*/ |
|
import dotenv from "dotenv"; |
|
import { createHmac, timingSafeEqual } from "crypto"; |
|
|
|
function webhookBodyToBuffer(rawBody: string | Buffer): Buffer { |
|
return Buffer.isBuffer(rawBody) |
|
? rawBody |
|
: Buffer.from(rawBody as string, "utf8"); |
|
} |
|
|
|
/** Remove one trailing LF, or one trailing CRLF (json.Encoder newline artifact). */ |
|
export function stripPaycrestWebhookTrailingNewline(body: Buffer): Buffer { |
|
if ( |
|
body.length >= 2 && |
|
body[body.length - 2] === 0x0d && |
|
body[body.length - 1] === 0x0a |
|
) { |
|
return body.subarray(0, body.length - 2); |
|
} |
|
if (body.length >= 1 && body[body.length - 1] === 0x0a) { |
|
return body.subarray(0, body.length - 1); |
|
} |
|
return body; |
|
} |
|
|
|
/** HMAC-SHA256 hex digest Paycrest sends in `X-Paycrest-Signature` (for debugging comparisons). */ |
|
export function computePaycrestWebhookSignatureHex( |
|
rawBody: string | Buffer, |
|
secret: string, |
|
): string { |
|
const key = secret.trim(); |
|
const bodyBuf = webhookBodyToBuffer(rawBody); |
|
return createHmac("sha256", key).update(bodyBuf).digest("hex"); |
|
} |
|
|
|
function macHexMatches(received: string, bodyBuf: Buffer, key: string): boolean { |
|
const expected = computePaycrestWebhookSignatureHex(bodyBuf, key).toLowerCase(); |
|
const r = received.trim().toLowerCase(); |
|
if (r.length !== expected.length) { |
|
return false; |
|
} |
|
try { |
|
return timingSafeEqual( |
|
Buffer.from(expected, "utf8"), |
|
Buffer.from(r, "utf8"), |
|
); |
|
} catch { |
|
return false; |
|
} |
|
} |
|
|
|
export function verifyPaycrestWebhookSignature( |
|
rawBody: string | Buffer, |
|
signatureHeader: string | undefined, |
|
secret: string, |
|
): boolean { |
|
const key = secret.trim(); |
|
if (!key || !signatureHeader?.trim()) { |
|
return false; |
|
} |
|
|
|
const received = signatureHeader.trim().toLowerCase(); |
|
const primary = webhookBodyToBuffer(rawBody); |
|
const trimmed = stripPaycrestWebhookTrailingNewline(primary); |
|
|
|
if (primary.equals(trimmed)) { |
|
return macHexMatches(received, primary, key); |
|
} |
|
|
|
return ( |
|
macHexMatches(received, primary, key) || macHexMatches(received, trimmed, key) |
|
); |
|
} |
|
|
|
export function getPaycrestSignatureHeader( |
|
headers?: Record<string, string | string[] | undefined>, |
|
): string | undefined { |
|
if (!headers) { |
|
return undefined; |
|
} |
|
for (const [k, v] of Object.entries(headers)) { |
|
if (k.toLowerCase() === "x-paycrest-signature") { |
|
const val = Array.isArray(v) ? v[0] : v; |
|
return typeof val === "string" ? val : undefined; |
|
} |
|
} |
|
return undefined; |
|
} |
|
|
|
/** Drop-in for processors: env secret + headers + raw body. */ |
|
export function verifyPaycrestWebhookFromRequest( |
|
rawBody: string | Buffer, |
|
headers: Record<string, string | string[] | undefined> | undefined, |
|
secret: string | undefined, |
|
): boolean { |
|
const sig = getPaycrestSignatureHeader(headers); |
|
return verifyPaycrestWebhookSignature(rawBody, sig, secret || ""); |
|
} |
|
|