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 @@ +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 new file mode 100644 index 0000000..358cd88 --- /dev/null +++ b/src/Webhook.php @@ -0,0 +1,198 @@ +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..3dffa04 --- /dev/null +++ b/tests/WebhookTest.php @@ -0,0 +1,240 @@ +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');