From c3688b5cc1b182f1895640053eca20d6fc65f4cc Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Thu, 27 Aug 2026 22:01:55 -0400 Subject: [PATCH] feat(webhooks): export standalone verifyWebhookSignature with timingSafeEqual and WebhookVerificationError (#617) --- src/index.ts | 8 ++- src/webhooks/verify.ts | 92 +++++++++++++++++++++++++++++----- test/webhooks/verify.test.ts | 97 ++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 test/webhooks/verify.test.ts diff --git a/src/index.ts b/src/index.ts index ebf7e30..bc1484c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -578,11 +578,17 @@ export { } from "./webhookReplay.js"; export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js"; +// Webhook verification utilities +export { + verifyWebhookSignature, + WebhookVerificationError, + assertWebhookSignature, +} from "./webhooks/verify.js"; + // Webhook middleware for receiving and verifying incoming webhooks export { createWebhookMiddleware, generateWebhookSignature, - verifyWebhookSignature, parseWebhookPayload, isValidEventType, isWebhookRequest, diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts index 88cab67..886d27d 100644 --- a/src/webhooks/verify.ts +++ b/src/webhooks/verify.ts @@ -11,29 +11,97 @@ import { createHmac, timingSafeEqual } from "crypto"; const HEX_PATTERN = /^[0-9a-f]+$/i; /** - * Verifies the `X-Stellar-Split-Signature` header against the raw request - * body using a timing-safe comparison. + * Error thrown when webhook signature verification fails in throwing assertion mode. + */ +export class WebhookVerificationError extends Error { + constructor(message = "Webhook signature verification failed") { + super(message); + this.name = "WebhookVerificationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Verifies a webhook signature against the raw payload and shared secret using HMAC-SHA256 + * and constant-time buffer comparison (`crypto.timingSafeEqual`) to prevent timing attacks. * - * @param secret - The shared HMAC secret configured for the webhook. - * @param rawBody - The exact, unparsed request body bytes as received. - * @param signatureHeader - The hex-encoded signature from the request header. - * @returns `true` only when the computed digest matches the header value. + * Supports both `(payload, signature, secret)` and `(secret, rawBody, signatureHeader)` calling conventions. + * Never throws on malformed signature or payload; returns `false` instead. + * + * @param payloadOrSecret - Raw body string or secret string + * @param signatureOrRawBody - Hex signature string or raw body string + * @param secretOrSignature - Secret string or hex signature string + * @returns `true` only when the computed HMAC-SHA256 digest matches the signature. */ +export function verifyWebhookSignature( + payload: string, + signature: string, + secret: string +): boolean; export function verifyWebhookSignature( secret: string, rawBody: string, signatureHeader: string +): boolean; +export function verifyWebhookSignature( + arg1: string, + arg2: string, + arg3: string ): boolean { - if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) { + if ( + typeof arg1 !== "string" || + typeof arg2 !== "string" || + typeof arg3 !== "string" + ) { return false; } - const expected = createHmac("sha256", secret).update(rawBody).digest(); - const provided = Buffer.from(signatureHeader, "hex"); + // Attempt 1: (payload: arg1, signature: arg2, secret: arg3) + const cleanSig1 = arg2.trim(); + if (HEX_PATTERN.test(cleanSig1) && cleanSig1.length % 2 === 0) { + try { + const expected1 = createHmac("sha256", arg3).update(arg1).digest(); + const provided1 = Buffer.from(cleanSig1, "hex"); + if (expected1.length === provided1.length && timingSafeEqual(expected1, provided1)) { + return true; + } + } catch { + // Continue to attempt 2 + } + } - if (expected.length !== provided.length) { - return false; + // Attempt 2: (secret: arg1, rawBody: arg2, signatureHeader: arg3) + const cleanSig2 = arg3.trim(); + if (HEX_PATTERN.test(cleanSig2) && cleanSig2.length % 2 === 0) { + try { + const expected2 = createHmac("sha256", arg1).update(arg2).digest(); + const provided2 = Buffer.from(cleanSig2, "hex"); + if (expected2.length === provided2.length && timingSafeEqual(expected2, provided2)) { + return true; + } + } catch { + // Return false + } } - return timingSafeEqual(expected, provided); + return false; +} + +/** + * Asserts that a webhook signature is valid, throwing {@link WebhookVerificationError} if it is not. + * + * @param payload - Raw payload string. + * @param signature - Hex signature string. + * @param secret - Shared HMAC secret. + * @throws {WebhookVerificationError} if signature verification returns `false`. + */ +export function assertWebhookSignature( + payload: string, + signature: string, + secret: string +): void { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError(); + } } + diff --git a/test/webhooks/verify.test.ts b/test/webhooks/verify.test.ts new file mode 100644 index 0000000..50bc249 --- /dev/null +++ b/test/webhooks/verify.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { createHmac } from "crypto"; +import { + verifyWebhookSignature, + assertWebhookSignature, + WebhookVerificationError, +} from "../../src/webhooks/verify.js"; +import * as IndexExports from "../../src/index.js"; + +const TEST_SECRET = "super_secret_signing_key_12345"; +const TEST_PAYLOAD = JSON.stringify({ + event: "invoice.paid", + invoiceId: "inv_stellar_9999", + amount: "5000000", + timestamp: 1724800000, +}); + +function signPayload(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("hex"); +} + +describe("verifyWebhookSignature (Issue #617)", () => { + it("exports verifyWebhookSignature and WebhookVerificationError from index.ts", () => { + expect(typeof IndexExports.verifyWebhookSignature).toBe("function"); + expect(typeof IndexExports.assertWebhookSignature).toBe("function"); + expect(IndexExports.WebhookVerificationError).toBeDefined(); + }); + + it("verifies valid HMAC-SHA256 signature (payload, signature, secret)", () => { + const signature = signPayload(TEST_PAYLOAD, TEST_SECRET); + const result = verifyWebhookSignature(TEST_PAYLOAD, signature, TEST_SECRET); + expect(result).toBe(true); + }); + + it("verifies valid HMAC-SHA256 signature with (secret, rawBody, signatureHeader)", () => { + const signature = signPayload(TEST_PAYLOAD, TEST_SECRET); + const result = verifyWebhookSignature(TEST_SECRET, TEST_PAYLOAD, signature); + expect(result).toBe(true); + }); + + it("returns false for wrong secret", () => { + const signature = signPayload(TEST_PAYLOAD, TEST_SECRET); + const result = verifyWebhookSignature(TEST_PAYLOAD, signature, "wrong_secret"); + expect(result).toBe(false); + }); + + it("returns false for tampered payload", () => { + const signature = signPayload(TEST_PAYLOAD, TEST_SECRET); + const tamperedPayload = TEST_PAYLOAD.replace("5000000", "9999999"); + const result = verifyWebhookSignature(tamperedPayload, signature, TEST_SECRET); + expect(result).toBe(false); + }); + + it("returns false for signature with mismatched length without throwing", () => { + const shortSig = "abcdef1234"; + const longSig = "a".repeat(128); + expect(verifyWebhookSignature(TEST_PAYLOAD, shortSig, TEST_SECRET)).toBe(false); + expect(verifyWebhookSignature(TEST_PAYLOAD, longSig, TEST_SECRET)).toBe(false); + }); + + it("returns false for non-hex signature without throwing", () => { + const invalidHex = "not_a_valid_hex_string_xyz!"; + expect(verifyWebhookSignature(TEST_PAYLOAD, invalidHex, TEST_SECRET)).toBe(false); + }); + + it("returns false for odd-length signature without throwing", () => { + const oddHex = "abc"; + expect(verifyWebhookSignature(TEST_PAYLOAD, oddHex, TEST_SECRET)).toBe(false); + }); + + it("returns false for non-string inputs without throwing", () => { + expect(verifyWebhookSignature(null as any, "sig", TEST_SECRET)).toBe(false); + expect(verifyWebhookSignature(TEST_PAYLOAD, null as any, TEST_SECRET)).toBe(false); + expect(verifyWebhookSignature(TEST_PAYLOAD, "sig", undefined as any)).toBe(false); + }); + + it("assertWebhookSignature passes with valid signature", () => { + const signature = signPayload(TEST_PAYLOAD, TEST_SECRET); + expect(() => { + assertWebhookSignature(TEST_PAYLOAD, signature, TEST_SECRET); + }).not.toThrow(); + }); + + it("assertWebhookSignature throws WebhookVerificationError with invalid signature", () => { + expect(() => { + assertWebhookSignature(TEST_PAYLOAD, "0".repeat(64), TEST_SECRET); + }).toThrow(WebhookVerificationError); + }); + + it("WebhookVerificationError instances have proper inheritance and name", () => { + const err = new WebhookVerificationError("Custom verification error"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(WebhookVerificationError); + expect(err.name).toBe("WebhookVerificationError"); + expect(err.message).toBe("Custom verification error"); + }); +});