|null $body * @return array */ public function signRequest(string $method, string $path, ?array $body): array { $timestamp = (string) time(); $nonce = (string) Str::uuid(); $bodyString = $body !== null ? $this->stableStringify($body) : ''; $bodyHash = hash('sha256', $bodyString); $canonical = implode("\n", [ strtoupper($method), $path, $bodyHash, $timestamp, $nonce, ]); $signature = hash_hmac('sha256', $canonical, $this->clientSecret); return [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Client-Id' => $this->clientId, 'X-Timestamp' => $timestamp, 'X-Nonce' => $nonce, 'X-Signature' => $signature, ]; } /** * @param array $query */ public function verifyReturnSignature(array $query): bool { return $this->matchReturnSignatureVariant($query) !== null; } /** * نام قالبی که امضا با آن خواند، یا null اگر هیچ‌کدام نخواند. * * پارتنر ممکن است مقادیر عددی را به‌صورت number امضا کند یا پارامتر خالی * (مثل track_number) را اصلاً در payload نگذارد؛ همه حالت‌ها بررسی می‌شود. * * @param array $query */ public function matchReturnSignatureVariant(array $query): ?string { $signature = (string) ($query['signature'] ?? ''); if ($signature === '' || !ctype_xdigit($signature)) { return null; } unset($query['signature']); foreach ($this->payloadVariants($query) as $name => $payload) { $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); if ($this->hashEqualsHex($signature, $expected)) { return $name; } } return null; } /** * @param array $body */ public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool { $signature = (string) ($signatureHeader ?? ''); if ($signature === '' || !ctype_xdigit($signature)) { return false; } // بدنه همان‌طور که آمده (نوع‌ها از JSON) اولویت دارد، بعد حالت‌های نرمال‌شده $candidates = array_merge( ['raw' => $body], $this->payloadVariants($body) ); foreach ($candidates as $payload) { $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); if ($this->hashEqualsHex($signature, $expected)) { return true; } } return false; } /** * حالت‌های ممکن payload برای مقایسه امضا (بدون تکرار). * * @param array $payload * @return array> */ private function payloadVariants(array $payload): array { $strings = []; foreach ($payload as $key => $value) { if (!is_string($key) || $key === '' || is_array($value) || $value === null) { continue; } $strings[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; } $withoutEmpty = array_filter($strings, static fn ($value) => $value !== ''); $variants = [ 'strings' => $strings, 'strings_without_empty' => $withoutEmpty, 'numeric' => $this->castNumericValues($strings), 'numeric_without_empty' => $this->castNumericValues($withoutEmpty), ]; $unique = []; $seen = []; foreach ($variants as $name => $variant) { $fingerprint = $this->stableStringify($variant); if (isset($seen[$fingerprint])) { continue; } $seen[$fingerprint] = true; $unique[$name] = $variant; } return $unique; } /** * @param array $payload * @return array */ private function castNumericValues(array $payload): array { foreach (['amount', 'timestamp'] as $key) { if (isset($payload[$key]) && is_string($payload[$key]) && ctype_digit($payload[$key])) { $payload[$key] = (int) $payload[$key]; } } return $payload; } public function isTimestampFresh(int|string|null $timestamp, int $skewSeconds = 300): bool { if ($timestamp === null || $timestamp === '') { return false; } return abs(time() - (int) $timestamp) <= $skewSeconds; } /** * @param mixed $value */ public function stableStringify($value): string { if ($value === null || !is_array($value)) { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (array_is_list($value)) { $parts = array_map(fn ($item) => $this->stableStringify($item), $value); return '[' . implode(',', $parts) . ']'; } ksort($value); $parts = []; foreach ($value as $key => $item) { $parts[] = '"' . $key . '":' . $this->stableStringify($item); } return '{' . implode(',', $parts) . '}'; } private function hashEqualsHex(string $a, string $b): bool { if (!ctype_xdigit($a) || !ctype_xdigit($b) || strlen($a) !== strlen($b)) { return false; } return hash_equals(strtolower($a), strtolower($b)); } }