diff --git a/src/client.ts b/src/client.ts index 262ffb2..8b44892 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2512,7 +2512,7 @@ export class StellarSplitClient extends TypedEventEmitter { * * @returns The new invoice ID and the transaction hash. * @example - * const result = await client.createInvoice({ /* params */ }); + * const result = await client.createInvoice({ ...params }); * @param params - The parameters for the method. * @throws {Error} If the method fails. */ @@ -2909,7 +2909,7 @@ export class StellarSplitClient extends TypedEventEmitter { * * @returns The transaction hash. * @example - * const result = await client.pay({ /* params */ }); + * const result = await client.pay({ ...params }); * @param params - The parameters for the method. * @throws {Error} If the method fails. */ @@ -3454,7 +3454,7 @@ export class StellarSplitClient extends TypedEventEmitter { * the invoice data is returned. Throws {@link TokenGateAccessDeniedError} when * the caller does not meet the balance requirement (and `strict !== false`). * @example - * const result = await client.getInvoice({ /* params */ }); + * const result = await client.getInvoice({ ...params }); * @param params - The parameters for the method. * @returns The result of the method. * @throws {Error} If the method fails. diff --git a/src/index.ts b/src/index.ts index 35bb785..5ffbbad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -585,7 +585,6 @@ export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js"; export { createWebhookMiddleware, generateWebhookSignature, - verifyWebhookSignature, parseWebhookPayload, isValidEventType, isWebhookRequest, @@ -610,6 +609,21 @@ export type { InvoiceCancelledData, InvoiceExpiredData, } from "./webhookMiddleware.js"; + +// Webhook delivery and verification utilities +export { + WebhookAgent, + WEBHOOK_SIGNATURE_HEADER, +} from "./webhooks/delivery.js"; +export type { + WebhookAgentOptions, + WebhookDeliveryInput, +} from "./webhooks/delivery.js"; +export { + verifyWebhookSignature, + WebhookVerificationError, + assertWebhookSignature, +} from "./webhooks/verify.js"; // --------------------------------------------------------------------------- // Lazy factories for heavy modules // --------------------------------------------------------------------------- diff --git a/src/webhooks/delivery.ts b/src/webhooks/delivery.ts index 36f2ecc..180756d 100644 --- a/src/webhooks/delivery.ts +++ b/src/webhooks/delivery.ts @@ -9,7 +9,14 @@ import { createHmac, randomUUID } from "crypto"; import { WebhookExhaustedError } from "../errors.js"; -import type { WebhookPayload } from "../types.js"; + +/** Outgoing webhook payload envelope delivered by WebhookAgent. */ +export interface WebhookPayload { + event_id: string; + event_type: string; + timestamp: string; + data: T; +} /** Header carrying the hex-encoded HMAC-SHA256 signature of the raw body. */ export const WEBHOOK_SIGNATURE_HEADER = "X-Stellar-Split-Signature"; diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts index 88cab67..5353033 100644 --- a/src/webhooks/verify.ts +++ b/src/webhooks/verify.ts @@ -7,33 +7,109 @@ */ import { createHmac, timingSafeEqual } from "crypto"; +import { StellarSplitError } from "../errors.js"; 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. + */ +export class WebhookVerificationError extends StellarSplitError { + constructor( + message: string = "Webhook signature verification failed", + context?: Record + ) { + super(message, "WEBHOOK_VERIFICATION_FAILED", context); + this.name = "WebhookVerificationError"; + Object.setPrototypeOf(this, new.target.prototype); + } + + /** + * Verifies the webhook signature and throws a {@link WebhookVerificationError} if invalid. + * + * @param payload - The raw webhook payload string. + * @param signature - The hex-encoded HMAC-SHA256 signature to verify. + * @param secret - The shared secret used to generate the signature. + * @throws {WebhookVerificationError} if verification fails. + */ + static verify(payload: string, signature: string, secret: string): void { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError(); + } + } + + /** + * Asserts that the webhook signature is valid, throwing {@link WebhookVerificationError} if not. + * + * @param payload - The raw webhook payload string. + * @param signature - The hex-encoded HMAC-SHA256 signature to verify. + * @param secret - The shared secret used to generate the signature. + * @throws {WebhookVerificationError} if verification fails. + */ + static assert(payload: string, signature: string, secret: string): void { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError(); + } + } +} + +/** + * Asserts that a webhook signature is valid against the payload and secret. + * Throws a {@link WebhookVerificationError} if the signature is invalid. + * + * @param payload - The raw webhook payload string. + * @param signature - The hex-encoded HMAC-SHA256 signature to verify. + * @param secret - The shared secret used to generate the signature. + * @throws {WebhookVerificationError} if signature verification fails. + */ +export function assertWebhookSignature( + payload: string, + signature: string, + secret: string +): void { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError(); + } +} + +/** + * Verifies a webhook signature against the raw payload using HMAC-SHA256 and + * constant-time comparison to prevent timing attacks. * + * @param payload - The raw request payload string as received. + * @param signature - The hex-encoded signature from the request header. * @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. + * @returns `true` if the computed HMAC matches the provided signature, `false` otherwise (never throws). */ export function verifyWebhookSignature( - secret: string, - rawBody: string, - signatureHeader: string + payload: string, + signature: string, + secret: string ): boolean { - if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) { - return false; - } + try { + if ( + typeof payload !== "string" || + typeof signature !== "string" || + typeof secret !== "string" + ) { + return false; + } + + const trimmedSignature = signature.trim(); + if (!HEX_PATTERN.test(trimmedSignature) || trimmedSignature.length % 2 !== 0) { + return false; + } - const expected = createHmac("sha256", secret).update(rawBody).digest(); - const provided = Buffer.from(signatureHeader, "hex"); + const expectedHex = createHmac("sha256", secret).update(payload).digest("hex"); + const expectedBuf = Buffer.from(expectedHex, "hex"); + const providedBuf = Buffer.from(trimmedSignature, "hex"); - if (expected.length !== provided.length) { + if (expectedBuf.length !== providedBuf.length) { + return false; + } + + return timingSafeEqual(expectedBuf, providedBuf); + } catch { return false; } - - return timingSafeEqual(expected, provided); } diff --git a/test/webhookVerify.test.ts b/test/webhookVerify.test.ts new file mode 100644 index 0000000..7c46009 --- /dev/null +++ b/test/webhookVerify.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { createHmac } from "crypto"; +import { + verifyWebhookSignature, + WebhookVerificationError, + assertWebhookSignature, +} from "../src/webhooks/verify.js"; +import { StellarSplitError } from "../src/errors.js"; +import * as sdk from "../src/index.js"; + +describe("verifyWebhookSignature and WebhookVerificationError", () => { + const secret = "super_secret_key_12345!@#"; + const payload = JSON.stringify({ + event: "invoice.paid", + timestamp: 1724930400, + data: { + invoiceId: "inv_stellar_123", + amount: "10000000", + payer: "GBBD...XYZ", + }, + }); + + const validSignature = createHmac("sha256", secret).update(payload).digest("hex"); + + describe("SDK root exports", () => { + it("exports verifyWebhookSignature and WebhookVerificationError from index.ts", () => { + expect(typeof sdk.verifyWebhookSignature).toBe("function"); + expect(typeof sdk.WebhookVerificationError).toBe("function"); + expect(typeof sdk.assertWebhookSignature).toBe("function"); + }); + }); + + describe("verifyWebhookSignature", () => { + it("returns true for a valid signature matching payload and secret", () => { + const isValid = verifyWebhookSignature(payload, validSignature, secret); + expect(isValid).toBe(true); + }); + + it("returns true for uppercase hex signature", () => { + const uppercaseSig = validSignature.toUpperCase(); + const isValid = verifyWebhookSignature(payload, uppercaseSig, secret); + expect(isValid).toBe(true); + }); + + it("returns false for an incorrect secret", () => { + const wrongSecret = "wrong_secret_key_67890"; + const isValid = verifyWebhookSignature(payload, validSignature, wrongSecret); + expect(isValid).toBe(false); + }); + + it("returns false when payload has been tampered with", () => { + const tamperedPayload = JSON.stringify({ + event: "invoice.paid", + timestamp: 1724930400, + data: { + invoiceId: "inv_stellar_123", + amount: "99999999", // Modified amount + payer: "GBBD...XYZ", + }, + }); + const isValid = verifyWebhookSignature(tamperedPayload, validSignature, secret); + expect(isValid).toBe(false); + }); + + it("returns false without throwing when signature length is mismatched", () => { + const shortSig = "abcdef123456"; + const longSig = validSignature + "abcdef"; + + expect(() => { + const resShort = verifyWebhookSignature(payload, shortSig, secret); + expect(resShort).toBe(false); + }).not.toThrow(); + + expect(() => { + const resLong = verifyWebhookSignature(payload, longSig, secret); + expect(resLong).toBe(false); + }).not.toThrow(); + }); + + it("returns false without throwing for odd length signature", () => { + const oddSig = "abcde"; + expect(() => { + const result = verifyWebhookSignature(payload, oddSig, secret); + expect(result).toBe(false); + }).not.toThrow(); + }); + + it("returns false without throwing for malformed non-hex signature", () => { + const nonHexSig = "not_a_valid_hex_signature_string_at_all!!"; + expect(() => { + const result = verifyWebhookSignature(payload, nonHexSig, secret); + expect(result).toBe(false); + }).not.toThrow(); + }); + + it("returns false without throwing for empty signature", () => { + expect(() => { + const result = verifyWebhookSignature(payload, "", secret); + expect(result).toBe(false); + }).not.toThrow(); + }); + + it("returns false without throwing for non-string inputs", () => { + // @ts-expect-error test invalid inputs + expect(verifyWebhookSignature(null, validSignature, secret)).toBe(false); + // @ts-expect-error test invalid inputs + expect(verifyWebhookSignature(payload, null, secret)).toBe(false); + // @ts-expect-error test invalid inputs + expect(verifyWebhookSignature(payload, validSignature, null)).toBe(false); + // @ts-expect-error test invalid inputs + expect(verifyWebhookSignature(undefined, undefined, undefined)).toBe(false); + }); + + it("verifies empty string payload correctly", () => { + const emptyPayload = ""; + const emptyPayloadSig = createHmac("sha256", secret).update(emptyPayload).digest("hex"); + expect(verifyWebhookSignature(emptyPayload, emptyPayloadSig, secret)).toBe(true); + expect(verifyWebhookSignature(emptyPayload, validSignature, secret)).toBe(false); + }); + }); + + describe("WebhookVerificationError", () => { + it("is an instance of StellarSplitError and Error", () => { + const err = new WebhookVerificationError("Custom error message"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err.name).toBe("WebhookVerificationError"); + expect(err.code).toBe("WEBHOOK_VERIFICATION_FAILED"); + expect(err.message).toBe("Custom error message"); + }); + + it("WebhookVerificationError.verify does not throw on valid signature", () => { + expect(() => { + WebhookVerificationError.verify(payload, validSignature, secret); + }).not.toThrow(); + }); + + it("WebhookVerificationError.verify throws WebhookVerificationError on invalid signature", () => { + expect(() => { + WebhookVerificationError.verify(payload, "invalid_signature", secret); + }).toThrow(WebhookVerificationError); + + expect(() => { + WebhookVerificationError.verify(payload, validSignature, "wrong_secret"); + }).toThrow(WebhookVerificationError); + }); + + it("WebhookVerificationError.assert does not throw on valid signature", () => { + expect(() => { + WebhookVerificationError.assert(payload, validSignature, secret); + }).not.toThrow(); + }); + + it("WebhookVerificationError.assert throws WebhookVerificationError on invalid signature", () => { + expect(() => { + WebhookVerificationError.assert(payload, "invalid_signature", secret); + }).toThrow(WebhookVerificationError); + }); + + it("assertWebhookSignature standalone function works as expected", () => { + expect(() => { + assertWebhookSignature(payload, validSignature, secret); + }).not.toThrow(); + + expect(() => { + assertWebhookSignature(payload, "tampered_signature", secret); + }).toThrow(WebhookVerificationError); + }); + }); +});