Skip to content
Closed
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
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 80 additions & 12 deletions src/webhooks/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

97 changes: 97 additions & 0 deletions test/webhooks/verify.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});