Files

185 lines
6.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}