Skip to content
Draft
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
6 changes: 3 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2512,7 +2512,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
*
* @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.
*/
Expand Down Expand Up @@ -2909,7 +2909,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
*
* @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.
*/
Expand Down Expand Up @@ -3454,7 +3454,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
* 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.
Expand Down
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,6 @@ export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js";
export {
createWebhookMiddleware,
generateWebhookSignature,
verifyWebhookSignature,
parseWebhookPayload,
isValidEventType,
isWebhookRequest,
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
9 changes: 8 additions & 1 deletion src/webhooks/delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = unknown> {
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";
Expand Down
108 changes: 92 additions & 16 deletions src/webhooks/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
) {
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);
}
172 changes: 172 additions & 0 deletions test/webhookVerify.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});