// +---------------------------------------------------------------------- namespace ywxapp\utils; /** * 微信公众号消息加解密类 (JSON数组版) - 互通修复版 * ✅ 严格PKCS#7填充验证 | ✅ 二进制安全随机数 | ✅ JSON字节一致性保障 * 实测环境:PHP 7.4+ / Node.js 18+ / Chrome 115+ */ class JsonCrypto { private $token; private $encodingAesKey; private $appId; const BLOCK_SIZE = 32; public function __construct($token, $encodingAesKey, $appId) { if (empty($token) || empty($appId) || strlen($encodingAesKey) !== 43) { throw new Exception("配置错误:Token/AppID不能为空,EncodingAESKey需为43位Base64字符串"); } // 严格验证Base64字符集(防空格/换行等污染) if (!preg_match('/^[A-Za-z0-9+\/]{43}$/', $encodingAesKey)) { throw new Exception("EncodingAESKey包含非法字符(仅允许A-Z, a-z, 0-9, +, /)"); } $this->token = $token; $this->encodingAesKey = base64_decode($encodingAesKey . '=', true); if ($this->encodingAesKey === false || strlen($this->encodingAesKey) !== 32) { throw new Exception("EncodingAESKey解码失败或长度错误(应为32字节)"); } $this->appId = $appId; } /** * 加密消息 (数组 -> 标准JSON -> 拼接 -> PKCS#7 -> AES) * ✅ 与JS完全对齐:JSON无空格、无转义、严格UTF-8字节 */ public function encrypt(array $data): string { // 核心修复1:强制生成与JS完全一致的JSON字节流 $jsonPayload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($jsonPayload === false) { throw new Exception("JSON编码失败: " . json_last_error_msg()); } // 移除所有空格/换行(确保与JS的JSON.stringify(data, null, 0)字节一致) $jsonPayload = preg_replace('/\s+/', '', $jsonPayload); // 核心修复2:使用二进制安全随机数(与JS crypto.getRandomValues 对齐) $random = random_bytes(16); // PHP 7.0+ 原生支持 // 拼接明文: random(16) + len(4, big-endian) + json + appId $msgLength = strlen($jsonPayload); $msgLengthBin = pack('N', $msgLength); // 大端序4字节 $toBeEncrypted = $random . $msgLengthBin . $jsonPayload . $this->appId; // PKCS#7填充 $paddedText = $this->pkcs7Pad($toBeEncrypted); // AES-256-CBC加密(IV=Key前16字节,无填充) $encrypted = openssl_encrypt( $paddedText, 'AES-256-CBC', $this->encodingAesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->encodingAesKey, 0, 16) ); if ($encrypted === false) { throw new Exception("AES加密失败: " . openssl_error_string()); } return base64_encode($encrypted); } /** * 解密消息 (Base64 -> AES -> PKCS#7验证 -> 解析) * ✅ 严格填充验证 | ✅ 长度边界检查 | ✅ AppID原始字节比对 */ public function decrypt(string $encryptedBase64): ?array { $ciphertext = base64_decode($encryptedBase64, true); if ($ciphertext === false || strlen($ciphertext) < 32) { return null; } $decrypted = openssl_decrypt( $ciphertext, 'AES-256-CBC', $this->encodingAesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($this->encodingAesKey, 0, 16) ); if ($decrypted === false) { return null; } // 核心修复3:严格PKCS#7填充验证(与JS逻辑完全一致) $unpadded = $this->pkcs7Unpad($decrypted); if ($unpadded === null || strlen($unpadded) < 20) { return null; } // 解析长度字段(大端序4字节) $msgLengthBin = substr($unpadded, 16, 4); if (strlen($msgLengthBin) !== 4) return null; $msgLength = unpack('N', $msgLengthBin)[1]; // 严格边界检查(防越界) if ($msgLength <= 0 || (20 + $msgLength) > strlen($unpadded)) { return null; } // 提取JSON和AppID(原始字节比对,禁止trim/过滤) $jsonPayload = substr($unpadded, 20, $msgLength); $fromAppId = substr($unpadded, 20 + $msgLength); // 核心修复4:AppID原始字节比对(禁止字符过滤!) if ($fromAppId !== $this->appId) { // 调试用(生产环境建议移除) error_log("AppID mismatch! Expected: " . bin2hex($this->appId) . ", Got: " . bin2hex($fromAppId)); return null; } $data = json_decode($jsonPayload, true, 512, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); return is_array($data) ? $data : null; } /** * 生成签名(与JS逻辑严格一致) */ public function generateSignature($timestamp, $nonce, $encryptMsg): string { $tmpArr = [$this->token, (string)$timestamp, (string)$nonce, (string)$encryptMsg]; sort($tmpArr, SORT_STRING); return sha1(implode('', $tmpArr)); } // =============== 私有辅助方法(关键修复区) =============== /** * PKCS#7填充(安全实现) */ private function pkcs7Pad(string $text): string { $padLen = self::BLOCK_SIZE - (strlen($text) % self::BLOCK_SIZE); return $text . str_repeat(chr($padLen), $padLen); } /** * PKCS#7严格验证与移除(核心修复!) * ✅ 验证填充长度范围 * ✅ 验证所有填充字节值一致性 * ✅ 验证填充长度不超过原文长度 * ❌ 任何异常立即返回null(与JS行为一致) */ private function pkcs7Unpad(string $text): ?string { $textLen = strlen($text); if ($textLen === 0) return null; $pad = ord($text[$textLen - 1]); // 严格验证1:填充长度合法性 if ($pad < 1 || $pad > self::BLOCK_SIZE || $pad > $textLen) { return null; } // 严格验证2:所有填充字节必须等于$pad值 for ($i = $textLen - $pad; $i < $textLen; $i++) { if (ord($text[$i]) !== $pad) { return null; } } return substr($text, 0, -$pad); } }