clientId = $this->resolveConfigValue($config, [ 'client_id', 'v2.client_id', 'prod.client_id', 'default.client_id', ]); $this->clientSecret = $this->resolveConfigValue($config, [ 'client_secret', 'v2.client_secret', 'prod.client_secret', 'default.client_secret', ]); $this->sm4KeyHex = $this->resolveConfigValue($config, [ 'sm4_key', 'encrypt_key', 'v2.sm4_key', 'crypto.sm4_key', 'sm4.key', ]); $this->sm4IvHex = $this->resolveConfigValue($config, [ 'sm4_iv', 'encrypt_iv', 'v2.sm4_iv', 'crypto.sm4_iv', 'sm4.iv', ]); $this->sm2PrivateKey = $this->resolveConfigValue($config, [ 'sm2_private_key', 'sign_private_key', 'v2.sm2_private_key', 'crypto.sm2_private_key', 'sm2.private_key', ]); $this->sm2PublicKey = $this->resolveConfigValue($config, [ 'sm2_public_key', 'verify_public_key', 'platform_public_key', 'v2.sm2_public_key', 'crypto.sm2_public_key', 'sm2.public_key', ]); } public static function fromConfig(?array $config = null): self { $config ??= (array) config('regulatory_platform', []); return new self($config); } public function getClientId(): ?string { return $this->clientId; } public function getClientSecret(): ?string { return $this->clientSecret; } public function buildRequestEnvelope( array $payload, bool $includeClientId = false, ?string $clientId = null, ?string $clientSecret = null ): array { $plainText = $this->encodeCanonicalJson($payload, true); return $this->buildRequestEnvelopeFromPlainText($plainText, $includeClientId, $clientId, $clientSecret); } public function buildRequestEnvelopeFromPlainText( string $plainText, bool $includeClientId = false, ?string $clientId = null, ?string $clientSecret = null ): array { $resolvedClientId = $clientId ?? $this->requireClientId(); $resolvedClientSecret = $clientSecret ?? $this->requireClientSecret(); $encryptedHex = $this->encrypt($plainText); $sign = $this->sign($this->buildSignString($resolvedClientId, $encryptedHex, $resolvedClientSecret)); $envelope = [ 'sign' => $sign, 'data' => $encryptedHex, ]; if ($includeClientId) { $envelope['client_id'] = $resolvedClientId; } return $envelope; } public function decodeResponseEnvelope( array $response, bool $verifySignature = false, ?string $clientId = null, ?string $clientSecret = null ): array { $envelope = $this->extractResponseEnvelope($response); $resolvedClientId = $clientId ?? $this->requireClientId(); $resolvedClientSecret = $clientSecret ?? $this->requireClientSecret(); if ($verifySignature) { $verified = $this->verify( $this->buildSignString($resolvedClientId, $envelope['data'], $resolvedClientSecret), $envelope['sign'] ); if (! $verified) { throw new BusinessException('监管平台响应验签失败'); } } $plainText = $this->decrypt($envelope['data']); $decoded = json_decode($plainText, true); if (! is_array($decoded)) { throw new BusinessException('监管平台响应解密成功,但 JSON 解析失败'); } return [ 'envelope' => $envelope, 'plain_text' => $plainText, 'decoded' => $decoded, ]; } public function buildSignString(string $clientId, string $encryptedHex, ?string $clientSecret = null): string { $resolvedClientSecret = $clientSecret ?? $this->requireClientSecret(); return sprintf('client_id=%s&data=%s&key=%s', $clientId, $encryptedHex, $resolvedClientSecret); } public function encodeCanonicalJson(array $payload, bool $removeEmptyValues = true): string { $normalized = $this->canonicalizePayload($payload, $removeEmptyValues); $json = json_encode( $normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION ); if ($json === false) { throw new BusinessException('监管平台请求 JSON 编码失败'); } return $json; } public function canonicalizePayload(array $payload, bool $removeEmptyValues = true): array { $normalized = $this->normalizeValue($payload, $removeEmptyValues); if (! is_array($normalized)) { throw new BusinessException('监管平台请求体格式错误'); } return $normalized; } public function encrypt(string $plainText): string { $key = $this->decodeHex($this->requireSm4Key(), 'SM4 key'); $iv = $this->decodeHex($this->requireSm4Iv(), 'SM4 iv'); $cipherText = openssl_encrypt($plainText, 'sm4-cbc', $key, OPENSSL_RAW_DATA, $iv); if ($cipherText === false) { throw new BusinessException('监管平台 SM4 加密失败: ' . $this->getLastOpenSslError()); } return strtolower(bin2hex($cipherText)); } public function decrypt(string $cipherHex): string { $cipherRaw = $this->decodeHex($cipherHex, 'SM4 cipher'); $key = $this->decodeHex($this->requireSm4Key(), 'SM4 key'); $iv = $this->decodeHex($this->requireSm4Iv(), 'SM4 iv'); $plainText = openssl_decrypt($cipherRaw, 'sm4-cbc', $key, OPENSSL_RAW_DATA, $iv); if ($plainText === false) { throw new BusinessException('监管平台 SM4 解密失败: ' . $this->getLastOpenSslError()); } return $plainText; } public function sign(string $plainText): string { $privateKey = openssl_pkey_get_private($this->resolveKeyMaterial($this->sm2PrivateKey, 'SM2 private key')); if ($privateKey === false) { throw new BusinessException('监管平台 SM2 私钥加载失败: ' . $this->getLastOpenSslError()); } $signature = ''; $result = openssl_sign($plainText, $signature, $privateKey, 'sm3'); if ($result !== true) { throw new BusinessException('监管平台 SM2 签名失败: ' . $this->getLastOpenSslError()); } return strtolower(bin2hex($signature)); } public function verify(string $plainText, string $signatureHex): bool { $publicKey = openssl_pkey_get_public($this->resolveKeyMaterial($this->sm2PublicKey, 'SM2 public key')); if ($publicKey === false) { throw new BusinessException('监管平台 SM2 公钥加载失败: ' . $this->getLastOpenSslError()); } $signatureRaw = $this->decodeHex($signatureHex, 'SM2 signature'); $result = openssl_verify($plainText, $signatureRaw, $publicKey, 'sm3'); if ($result === -1) { throw new BusinessException('监管平台 SM2 验签失败: ' . $this->getLastOpenSslError()); } return $result === 1; } protected function extractResponseEnvelope(array $response): array { if (isset($response['data']) && is_array($response['data']) && isset($response['data']['data'])) { $response = $response['data']; } $sign = $response['sign'] ?? null; $data = $response['data'] ?? null; if (! is_string($sign) || ! is_string($data) || $sign === '' || $data === '') { throw new BusinessException('监管平台响应报文缺少 sign/data'); } return [ 'sign' => $sign, 'data' => $data, ]; } protected function normalizeValue(mixed $value, bool $removeEmptyValues): mixed { if (is_array($value)) { if ($this->isAssoc($value)) { $normalized = []; ksort($value); foreach ($value as $key => $item) { $item = $this->normalizeValue($item, $removeEmptyValues); if ($removeEmptyValues && $this->isEmptyValue($item)) { continue; } $normalized[$key] = $item; } return $normalized; } $normalized = []; foreach ($value as $item) { $item = $this->normalizeValue($item, $removeEmptyValues); if ($removeEmptyValues && $this->isEmptyValue($item)) { continue; } $normalized[] = $item; } return $normalized; } return $value; } protected function isAssoc(array $value): bool { return array_keys($value) !== range(0, count($value) - 1); } protected function isEmptyValue(mixed $value): bool { if ($value === null) { return true; } if (is_string($value)) { return trim($value) === ''; } if (is_array($value)) { return $value === []; } return false; } protected function decodeHex(string $value, string $label): string { $value = trim($value); if ($value === '' || strlen($value) % 2 !== 0 || ! ctype_xdigit($value)) { throw new BusinessException(sprintf('监管平台 %s 不是合法十六进制字符串', $label)); } $decoded = hex2bin($value); if ($decoded === false) { throw new BusinessException(sprintf('监管平台 %s 十六进制解码失败', $label)); } return $decoded; } protected function resolveKeyMaterial(?string $key, string $label): string { $key = trim((string) $key); if ($key === '') { throw new BusinessException(sprintf('监管平台 %s 未配置', $label)); } if (str_starts_with($key, 'file://')) { $key = substr($key, 7); } if (is_file($key)) { $contents = file_get_contents($key); if ($contents === false || trim($contents) === '') { throw new BusinessException(sprintf('监管平台 %s 文件读取失败', $label)); } return $contents; } if ($label === 'SM2 private key' && $this->isRawHexPrivateKey($key)) { return $this->buildSm2PrivateKeyPem($key, $this->sm2PublicKey); } if ($label === 'SM2 public key' && $this->isRawHexPublicKey($key)) { return $this->buildSm2PublicKeyPem($key); } return $key; } protected function resolveConfigValue(array $config, array $paths): ?string { foreach ($paths as $path) { $value = $this->getByPath($config, $path); if ($value !== null && $value !== '') { return (string) $value; } } return null; } protected function getByPath(array $config, string $path): mixed { $current = $config; foreach (explode('.', $path) as $segment) { if (! is_array($current) || ! array_key_exists($segment, $current)) { return null; } $current = $current[$segment]; } return $current; } protected function requireClientId(): string { if (empty($this->clientId)) { throw new BusinessException('监管平台 client_id 未配置'); } return $this->clientId; } protected function requireClientSecret(): string { if (empty($this->clientSecret)) { throw new BusinessException('监管平台 client_secret 未配置'); } return $this->clientSecret; } protected function requireSm4Key(): string { if (empty($this->sm4KeyHex)) { throw new BusinessException('监管平台 SM4 key 未配置'); } return $this->sm4KeyHex; } protected function requireSm4Iv(): string { if (empty($this->sm4IvHex)) { throw new BusinessException('监管平台 SM4 iv 未配置'); } return $this->sm4IvHex; } protected function getLastOpenSslError(): string { $errors = []; while (($error = openssl_error_string()) !== false) { $errors[] = $error; } return $errors === [] ? 'unknown openssl error' : implode(' | ', $errors); } protected function isRawHexPrivateKey(string $key): bool { return strlen($key) === 64 && ctype_xdigit($key); } protected function isRawHexPublicKey(string $key): bool { if (! ctype_xdigit($key)) { return false; } return in_array(strlen($key), [128, 130], true); } protected function buildSm2PrivateKeyPem(string $privateKeyHex, ?string $publicKeyHex = null): string { $privateKeyRaw = $this->decodeHex($privateKeyHex, 'SM2 private key'); $sequence = $this->derSequence( $this->derInteger(1), $this->derOctetString($privateKeyRaw), $this->derContextSpecific(0, $this->derObjectIdentifier(self::SM2_CURVE_OID)) ); if (is_string($publicKeyHex) && $this->isRawHexPublicKey(trim($publicKeyHex))) { $publicKeyRaw = $this->normalizeRawPublicKey(trim($publicKeyHex)); $sequence .= $this->derContextSpecific(1, $this->derBitString($publicKeyRaw)); $sequence = $this->derSequence($this->derInteger(1), $this->derOctetString($privateKeyRaw), $this->derContextSpecific(0, $this->derObjectIdentifier(self::SM2_CURVE_OID)), $this->derContextSpecific(1, $this->derBitString($publicKeyRaw))); } return $this->pemEncode('EC PRIVATE KEY', $sequence); } protected function buildSm2PublicKeyPem(string $publicKeyHex): string { $publicKeyRaw = $this->normalizeRawPublicKey($publicKeyHex); $algorithm = $this->derSequence( $this->derObjectIdentifier(self::EC_PUBLIC_KEY_OID), $this->derObjectIdentifier(self::SM2_CURVE_OID) ); $subjectPublicKeyInfo = $this->derSequence( $algorithm, $this->derBitString($publicKeyRaw) ); return $this->pemEncode('PUBLIC KEY', $subjectPublicKeyInfo); } protected function normalizeRawPublicKey(string $publicKeyHex): string { $publicKeyHex = strtolower($publicKeyHex); if (strlen($publicKeyHex) === 128) { $publicKeyHex = '04' . $publicKeyHex; } return $this->decodeHex($publicKeyHex, 'SM2 public key'); } protected function pemEncode(string $label, string $der): string { return "-----BEGIN {$label}-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END {$label}-----\n"; } protected function derSequence(string ...$parts): string { return $this->derWrap(0x30, implode('', $parts)); } protected function derInteger(int $value): string { $encoded = ''; $current = $value; do { $encoded = chr($current & 0xff) . $encoded; $current >>= 8; } while ($current > 0); if ((ord($encoded[0]) & 0x80) !== 0) { $encoded = "\x00" . $encoded; } return $this->derWrap(0x02, $encoded); } protected function derOctetString(string $value): string { return $this->derWrap(0x04, $value); } protected function derBitString(string $value): string { return $this->derWrap(0x03, "\x00" . $value); } protected function derObjectIdentifier(string $oid): string { $parts = array_map('intval', explode('.', $oid)); if (count($parts) < 2) { throw new BusinessException('监管平台 OID 格式错误'); } $encoded = chr(($parts[0] * 40) + $parts[1]); for ($i = 2; $i < count($parts); $i++) { $encoded .= $this->encodeOidPart($parts[$i]); } return $this->derWrap(0x06, $encoded); } protected function derContextSpecific(int $index, string $value): string { return $this->derWrap(0xa0 + $index, $value); } protected function derWrap(int $tag, string $value): string { return chr($tag) . $this->derLength(strlen($value)) . $value; } protected function derLength(int $length): string { if ($length < 0x80) { return chr($length); } $encoded = ''; $current = $length; while ($current > 0) { $encoded = chr($current & 0xff) . $encoded; $current >>= 8; } return chr(0x80 | strlen($encoded)) . $encoded; } protected function encodeOidPart(int $value): string { if ($value === 0) { return "\x00"; } $encoded = ''; $current = $value; while ($current > 0) { $encoded = chr($current & 0x7f) . $encoded; $current >>= 7; } $length = strlen($encoded); for ($i = 0; $i < $length - 1; $i++) { $encoded[$i] = chr(ord($encoded[$i]) | 0x80); } return $encoded; } }