secret = base64_decode($secret); } /** * Create a webhook instance from a raw secret (no base64 decoding) */ public static function fromRaw(string $secret): self { $obj = new self(''); $obj->secret = $secret; return $obj; } /** * Verify webhook signature and return decoded payload */ public function verify(string $payload, array $headers): array { // Check for required headers if ( ! isset($headers['webhook-id']) && ! isset($headers['webhook-timestamp']) && ! isset($headers['webhook-signature']) ) { throw new WebhookVerificationException('Missing required headers'); } $msgId = $headers['webhook-id'] ?? null; $msgTimestamp = $headers['webhook-timestamp'] ?? null; $msgSignature = $headers['webhook-signature'] ?? null; if (! $msgId || ! $msgTimestamp || ! $msgSignature) { throw new WebhookVerificationException('Missing required headers'); } // Verify timestamp is within tolerance $timestamp = $this->verifyTimestamp($msgTimestamp); // Generate expected signature $signature = $this->sign($msgId, $timestamp, $payload); $expectedSignature = explode(',', $signature, 2)[1]; // Check all provided signatures $passedSignatures = explode(' ', $msgSignature); foreach ($passedSignatures as $versionedSignature) { $sigParts = explode(',', $versionedSignature, 2); if (count($sigParts) < 2) { continue; } $version = $sigParts[0]; $passedSignature = $sigParts[1]; if (strcmp($version, 'v1') !== 0) { continue; } if (hash_equals($expectedSignature, $passedSignature)) { return json_decode($payload, true) ?? []; } } throw new WebhookVerificationException('No matching signature found'); } /** * Generate webhook signature */ public function sign(string $msgId, int $timestamp, string $payload): string { $timestamp = (string) $timestamp; if (! $this->isPositiveInteger($timestamp)) { throw new WebhookSigningException('Invalid timestamp'); } $toSign = "{$msgId}.{$timestamp}.{$payload}"; $hexHash = hash_hmac('sha256', $toSign, $this->secret); $signature = base64_encode(pack('H*', $hexHash)); return "v1,{$signature}"; } /** * Verify timestamp is within acceptable tolerance */ private function verifyTimestamp(string $timestampHeader): int { $now = time(); try { $timestamp = intval($timestampHeader, 10); } catch (\Exception $e) { throw new WebhookVerificationException('Invalid Signature Headers'); } if ($timestamp < ($now - self::TOLERANCE)) { throw new WebhookVerificationException('Message timestamp too old'); } if ($timestamp > ($now + self::TOLERANCE)) { throw new WebhookVerificationException('Message timestamp too new'); } return $timestamp; } /** * Check if value is a positive integer */ private function isPositiveInteger(string $value): bool { return is_numeric($value) && ! is_float($value + 0) && (int) $value == $value && (int) $value > 0; } }