185 lines
6.8 KiB
PHP
185 lines
6.8 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
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);
|
||
}
|
||
} |