596 lines
18 KiB
PHP
596 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Extend\RegulatoryPlatform;
|
|
|
|
use App\Exception\BusinessException;
|
|
|
|
use function Hyperf\Config\config;
|
|
|
|
class RegulatoryPlatformProtocol
|
|
{
|
|
protected const SM2_CURVE_OID = '1.2.156.10197.1.301';
|
|
|
|
protected const EC_PUBLIC_KEY_OID = '1.2.840.10045.2.1';
|
|
|
|
protected ?string $clientId;
|
|
|
|
protected ?string $clientSecret;
|
|
|
|
protected ?string $sm4KeyHex;
|
|
|
|
protected ?string $sm4IvHex;
|
|
|
|
protected ?string $sm2PrivateKey;
|
|
|
|
protected ?string $sm2PublicKey;
|
|
|
|
public function __construct(array $config = [])
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|