From 7cde2fcf82d3a8187c70d6a4d6f4ed51b733df39 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 26 Nov 2025 22:42:42 +0100 Subject: [PATCH 1/2] feat: add webhook signature verification support Implemented a `Webhook` class to verify webhook payloads with signature validation and timestamp tolerance. Added exception handling for invalid signatures, incorrect timestamps, and JSON decoding errors. Comprehensive tests covering various edge cases ensure reliability and robustness. --- composer.json | 1 + src/Exceptions/InvalidSignatureException.php | 5 + src/Exceptions/JsonDecodeException.php | 5 + .../TimestampToleranceException.php | 7 + .../WebhookVerificationException.php | 7 + src/Webhook.php | 198 +++++++++++++++ tests/WebhookTest.php | 239 ++++++++++++++++++ 7 files changed, 462 insertions(+) create mode 100644 src/Exceptions/InvalidSignatureException.php create mode 100644 src/Exceptions/JsonDecodeException.php create mode 100644 src/Exceptions/TimestampToleranceException.php create mode 100644 src/Exceptions/WebhookVerificationException.php create mode 100644 src/Webhook.php create mode 100644 tests/WebhookTest.php diff --git a/composer.json b/composer.json index 0fa8524..f08a425 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "lettermint/lettermint-php", + "version": "1.4.99", "description": "Official Lettermint PHP SDK.", "type": "library", "keywords": [ diff --git a/src/Exceptions/InvalidSignatureException.php b/src/Exceptions/InvalidSignatureException.php new file mode 100644 index 0000000..6c95265 --- /dev/null +++ b/src/Exceptions/InvalidSignatureException.php @@ -0,0 +1,5 @@ +secret = $secret; + $this->tolerance = $tolerance; + } + + /** + * Verify a webhook signature and return the decoded payload. + * + * @param string $payload The raw request body + * @param string $signature The signature header value (format: t={timestamp},v1={hash}) + * @param int|null $timestamp Optional timestamp from delivery header for cross-validation + * @return array The decoded webhook payload + * + * @throws WebhookVerificationException If signature format is invalid or timestamps mismatch + * @throws InvalidSignatureException If signature doesn't match + * @throws TimestampToleranceException If timestamp is outside tolerance window + * @throws JsonDecodeException If payload is not valid JSON + */ + public function verify(string $payload, string $signature, ?int $timestamp = null): array + { + $parsedSignature = $this->parseSignature($signature); + + $signatureTimestamp = $parsedSignature['timestamp']; + $expectedSignature = $parsedSignature['signature']; + + if ($timestamp !== null && $timestamp !== $signatureTimestamp) { + throw new WebhookVerificationException('Timestamp mismatch between signature and delivery headers'); + } + + $this->validateTimestamp($signatureTimestamp); + + $signedContent = $signatureTimestamp.'.'.$payload; + $computedSignature = hash_hmac('sha256', $signedContent, $this->secret); + + if (! hash_equals($computedSignature, $expectedSignature)) { + throw new InvalidSignatureException('Signature verification failed'); + } + + $data = json_decode($payload, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new JsonDecodeException('Failed to decode webhook payload: '.json_last_error_msg()); + } + + return $data; + } + + /** + * Verify a webhook using HTTP headers and return the decoded payload. + * + * @param array $headers HTTP headers from the request + * @param string $payload The raw request body + * @return array The decoded webhook payload + * + * @throws WebhookVerificationException If required headers are missing or verification fails + * @throws InvalidSignatureException If signature doesn't match + * @throws TimestampToleranceException If timestamp is outside tolerance window + * @throws JsonDecodeException If payload is not valid JSON + */ + public function verifyHeaders(array $headers, string $payload): array + { + $headers = $this->normalizeHeaders($headers); + + $signature = $headers[strtolower(self::SIGNATURE_HEADER)] ?? null; + $timestamp = $headers[strtolower(self::DELIVERY_HEADER)] ?? null; + + if ($signature === null) { + throw new WebhookVerificationException('Missing signature header: '.self::SIGNATURE_HEADER); + } + + if ($timestamp === null) { + throw new WebhookVerificationException('Missing delivery header: '.self::DELIVERY_HEADER); + } + + return $this->verify($payload, $signature, (int) $timestamp); + } + + /** + * Static convenience method to verify a webhook signature. + * + * @param string $payload The raw request body + * @param string $signature The signature header value (format: t={timestamp},v1={hash}) + * @param string $secret The webhook signing secret + * @param int|null $timestamp Optional timestamp from delivery header for cross-validation + * @param int $tolerance Maximum allowed time difference in seconds (default: 300) + * @return array The decoded webhook payload + * + * @throws \InvalidArgumentException If secret is empty + * @throws WebhookVerificationException If signature format is invalid or timestamps mismatch + * @throws InvalidSignatureException If signature doesn't match + * @throws TimestampToleranceException If timestamp is outside tolerance window + * @throws JsonDecodeException If payload is not valid JSON + */ + public static function verifySignature( + string $payload, + string $signature, + string $secret, + ?int $timestamp = null, + int $tolerance = self::DEFAULT_TOLERANCE + ): array { + $webhook = new self($secret, $tolerance); + + return $webhook->verify($payload, $signature, $timestamp); + } + + private function parseSignature(string $signature): array + { + $parts = explode(',', $signature); + + $timestamp = null; + $signatureHash = null; + + foreach ($parts as $part) { + $keyValue = explode('=', $part, 2); + if (count($keyValue) !== 2) { + continue; + } + + [$key, $value] = $keyValue; + + if ($key === 't') { + $timestamp = (int) $value; + } elseif ($key === 'v1') { + $signatureHash = $value; + } + } + + if ($timestamp === null || $signatureHash === null) { + throw new WebhookVerificationException('Invalid signature format. Expected format: t={timestamp},v1={signature}'); + } + + return [ + 'timestamp' => $timestamp, + 'signature' => $signatureHash, + ]; + } + + private function validateTimestamp(int $timestamp): void + { + $currentTime = time(); + $difference = abs($currentTime - $timestamp); + + if ($difference > $this->tolerance) { + throw new TimestampToleranceException( + sprintf( + 'Timestamp outside tolerance window. Difference: %d seconds, Tolerance: %d seconds', + $difference, + $this->tolerance + ) + ); + } + } + + private function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $key => $value) { + $normalized[strtolower($key)] = $value; + } + + return $normalized; + } +} diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php new file mode 100644 index 0000000..72d67d1 --- /dev/null +++ b/tests/WebhookTest.php @@ -0,0 +1,239 @@ +verify(TEST_PAYLOAD, $signature, $timestamp); + + expect($result)->toBeArray() + ->and($result['event'])->toBe('email.sent') + ->and($result['data']['id'])->toBe('123'); +}); + +test('it verifies signature without explicit timestamp parameter', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $result = $webhook->verify(TEST_PAYLOAD, $signature); + + expect($result)->toBeArray() + ->and($result['event'])->toBe('email.sent'); +}); + +test('it rejects invalid signature', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $invalidSignature = "t={$timestamp},v1=invalid_signature_hash"; + + $webhook->verify(TEST_PAYLOAD, $invalidSignature, $timestamp); +})->throws(InvalidSignatureException::class, 'Signature verification failed'); + +test('it rejects signature with wrong secret', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp, 'wrong_secret'); + + $webhook->verify(TEST_PAYLOAD, $signature, $timestamp); +})->throws(InvalidSignatureException::class); + +test('it rejects signature with modified payload', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + $modifiedPayload = '{"event":"email.sent","data":{"id":"456"}}'; + + $webhook->verify($modifiedPayload, $signature, $timestamp); +})->throws(InvalidSignatureException::class); + +test('it rejects timestamp outside tolerance window', function () { + $webhook = new Webhook(TEST_SECRET, 300); + $oldTimestamp = time() - 400; + $signature = generateValidSignature(TEST_PAYLOAD, $oldTimestamp); + + $webhook->verify(TEST_PAYLOAD, $signature, $oldTimestamp); +})->throws(TimestampToleranceException::class); + +test('it accepts timestamp within tolerance window', function () { + $webhook = new Webhook(TEST_SECRET, 300); + $recentTimestamp = time() - 200; + $signature = generateValidSignature(TEST_PAYLOAD, $recentTimestamp); + + $result = $webhook->verify(TEST_PAYLOAD, $signature, $recentTimestamp); + + expect($result)->toBeArray(); +}); + +test('it allows custom tolerance configuration', function () { + $webhook = new Webhook(TEST_SECRET, 600); + $timestamp = time() - 500; + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $result = $webhook->verify(TEST_PAYLOAD, $signature, $timestamp); + + expect($result)->toBeArray(); +}); + +test('it rejects malformed signature format', function () { + $webhook = new Webhook(TEST_SECRET); + $malformedSignature = 'invalid_format'; + + $webhook->verify(TEST_PAYLOAD, $malformedSignature); +})->throws(WebhookVerificationException::class, 'Invalid signature format'); + +test('it rejects signature missing timestamp', function () { + $webhook = new Webhook(TEST_SECRET); + $signature = 'v1=somehash'; + + $webhook->verify(TEST_PAYLOAD, $signature); +})->throws(WebhookVerificationException::class, 'Invalid signature format'); + +test('it rejects signature missing hash', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = "t={$timestamp}"; + + $webhook->verify(TEST_PAYLOAD, $signature); +})->throws(WebhookVerificationException::class, 'Invalid signature format'); + +test('it verifies using static method', function () { + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $result = Webhook::verifySignature( + TEST_PAYLOAD, + $signature, + TEST_SECRET, + $timestamp + ); + + expect($result)->toBeArray() + ->and($result['event'])->toBe('email.sent'); +}); + +test('it verifies using static method with custom tolerance', function () { + $timestamp = time() - 500; + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $result = Webhook::verifySignature( + TEST_PAYLOAD, + $signature, + TEST_SECRET, + $timestamp, + 600 + ); + + expect($result)->toBeArray(); +}); + +test('it verifies from headers array', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $headers = [ + 'X-Lettermint-Signature' => $signature, + 'X-Lettermint-Delivery' => (string) $timestamp, + ]; + + $result = $webhook->verifyHeaders($headers, TEST_PAYLOAD); + + expect($result)->toBeArray() + ->and($result['event'])->toBe('email.sent'); +}); + +test('it normalizes header names case-insensitively', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $headers = [ + 'x-lettermint-signature' => $signature, + 'x-lettermint-delivery' => (string) $timestamp, + ]; + + $result = $webhook->verifyHeaders($headers, TEST_PAYLOAD); + + expect($result)->toBeArray(); +}); + +test('it rejects when signature header is missing', function () { + $webhook = new Webhook(TEST_SECRET); + $headers = [ + 'X-Lettermint-Delivery' => (string) time(), + ]; + + $webhook->verifyHeaders($headers, TEST_PAYLOAD); +})->throws(WebhookVerificationException::class, 'Missing signature header'); + +test('it rejects when delivery header is missing', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp = time(); + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp); + + $headers = [ + 'X-Lettermint-Signature' => $signature, + ]; + + $webhook->verifyHeaders($headers, TEST_PAYLOAD); +})->throws(WebhookVerificationException::class, 'Missing delivery header'); + +test('it rejects timestamp mismatch between signature and delivery headers', function () { + $webhook = new Webhook(TEST_SECRET); + $timestamp1 = time(); + $timestamp2 = time() - 10; + $signature = generateValidSignature(TEST_PAYLOAD, $timestamp1); + + $webhook->verify(TEST_PAYLOAD, $signature, $timestamp2); +})->throws(WebhookVerificationException::class, 'Timestamp mismatch'); + +test('it handles future timestamps within tolerance', function () { + $webhook = new Webhook(TEST_SECRET, 300); + $futureTimestamp = time() + 100; + $signature = generateValidSignature(TEST_PAYLOAD, $futureTimestamp); + + $result = $webhook->verify(TEST_PAYLOAD, $signature, $futureTimestamp); + + expect($result)->toBeArray(); +}); + +test('it rejects future timestamps outside tolerance', function () { + $webhook = new Webhook(TEST_SECRET, 300); + $futureTimestamp = time() + 400; + $signature = generateValidSignature(TEST_PAYLOAD, $futureTimestamp); + + $webhook->verify(TEST_PAYLOAD, $signature, $futureTimestamp); +})->throws(TimestampToleranceException::class); + +test('it rejects empty secret', function () { + new Webhook(''); +})->throws(InvalidArgumentException::class, 'Webhook secret cannot be empty'); + +test('it rejects invalid JSON payload', function () { + $webhook = new Webhook(TEST_SECRET); + $invalidJson = 'not valid json'; + $timestamp = time(); + $signedContent = $timestamp.'.'.$invalidJson; + $signatureHash = hash_hmac('sha256', $signedContent, TEST_SECRET); + $signature = "t={$timestamp},v1={$signatureHash}"; + + $webhook->verify($invalidJson, $signature, $timestamp); +})->throws(JsonDecodeException::class, 'Failed to decode webhook payload'); From 328821bd157f3b874dcb782499c20c5a6ee2e6b7 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Thu, 27 Nov 2025 16:57:06 +0100 Subject: [PATCH 2/2] chore: apply code formatting and cleanup Refactored code for consistent formatting and spacing, improving readability and maintaining compliance with PHPDoc standards. Updated minor code constructs to align with PHP best practices and removed redundant whitespace. These changes ensure a more standardized codebase. --- composer.json | 1 - .../TimestampToleranceException.php | 4 +--- src/Lettermint.php | 6 ++++- src/Webhook.php | 24 +++++++++---------- tests/WebhookTest.php | 3 ++- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index f08a425..0fa8524 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "name": "lettermint/lettermint-php", - "version": "1.4.99", "description": "Official Lettermint PHP SDK.", "type": "library", "keywords": [ diff --git a/src/Exceptions/TimestampToleranceException.php b/src/Exceptions/TimestampToleranceException.php index f0eaca7..a82c28d 100644 --- a/src/Exceptions/TimestampToleranceException.php +++ b/src/Exceptions/TimestampToleranceException.php @@ -2,6 +2,4 @@ namespace Lettermint\Exceptions; -class TimestampToleranceException extends WebhookVerificationException -{ -} +class TimestampToleranceException extends WebhookVerificationException {} diff --git a/src/Lettermint.php b/src/Lettermint.php index cdd568f..9664bec 100644 --- a/src/Lettermint.php +++ b/src/Lettermint.php @@ -2,8 +2,8 @@ namespace Lettermint; -use Lettermint\Endpoints\EmailEndpoint; use Lettermint\Client\HttpClient; +use Lettermint\Endpoints\EmailEndpoint; /** * @property-read EmailEndpoint $email Access the send endpoint {@see EmailEndpoint} @@ -11,8 +11,11 @@ class Lettermint { private string $apiToken; + private string $baseUrl; + private HttpClient $httpClient; + private array $endpoints = []; protected array $endpointRegistry = [ @@ -35,6 +38,7 @@ public function __get($name) if (array_key_exists($name, $this->endpointRegistry)) { $class = $this->endpointRegistry[$name]; $this->endpoints[$name] = new $class($this->httpClient); + return $this->endpoints[$name]; } diff --git a/src/Webhook.php b/src/Webhook.php index 6206770..358cd88 100644 --- a/src/Webhook.php +++ b/src/Webhook.php @@ -22,8 +22,8 @@ final class Webhook /** * Create a new webhook verifier instance. * - * @param string $secret The webhook signing secret - * @param int $tolerance Maximum allowed time difference in seconds (default: 300) + * @param string $secret The webhook signing secret + * @param int $tolerance Maximum allowed time difference in seconds (default: 300) * * @throws \InvalidArgumentException If secret is empty */ @@ -40,9 +40,9 @@ public function __construct(string $secret, int $tolerance = self::DEFAULT_TOLER /** * Verify a webhook signature and return the decoded payload. * - * @param string $payload The raw request body - * @param string $signature The signature header value (format: t={timestamp},v1={hash}) - * @param int|null $timestamp Optional timestamp from delivery header for cross-validation + * @param string $payload The raw request body + * @param string $signature The signature header value (format: t={timestamp},v1={hash}) + * @param int|null $timestamp Optional timestamp from delivery header for cross-validation * @return array The decoded webhook payload * * @throws WebhookVerificationException If signature format is invalid or timestamps mismatch @@ -82,8 +82,8 @@ public function verify(string $payload, string $signature, ?int $timestamp = nul /** * Verify a webhook using HTTP headers and return the decoded payload. * - * @param array $headers HTTP headers from the request - * @param string $payload The raw request body + * @param array $headers HTTP headers from the request + * @param string $payload The raw request body * @return array The decoded webhook payload * * @throws WebhookVerificationException If required headers are missing or verification fails @@ -112,11 +112,11 @@ public function verifyHeaders(array $headers, string $payload): array /** * Static convenience method to verify a webhook signature. * - * @param string $payload The raw request body - * @param string $signature The signature header value (format: t={timestamp},v1={hash}) - * @param string $secret The webhook signing secret - * @param int|null $timestamp Optional timestamp from delivery header for cross-validation - * @param int $tolerance Maximum allowed time difference in seconds (default: 300) + * @param string $payload The raw request body + * @param string $signature The signature header value (format: t={timestamp},v1={hash}) + * @param string $secret The webhook signing secret + * @param int|null $timestamp Optional timestamp from delivery header for cross-validation + * @param int $tolerance Maximum allowed time difference in seconds (default: 300) * @return array The decoded webhook payload * * @throws \InvalidArgumentException If secret is empty diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php index 72d67d1..3dffa04 100644 --- a/tests/WebhookTest.php +++ b/tests/WebhookTest.php @@ -11,8 +11,9 @@ function generateValidSignature(string $payload, int $timestamp, string $secret = TEST_SECRET): string { - $signedContent = $timestamp . '.' . $payload; + $signedContent = $timestamp.'.'.$payload; $signature = hash_hmac('sha256', $signedContent, $secret); + return "t={$timestamp},v1={$signature}"; }