Files
kiri-wchat/wx/V3/Libs/WxMsgCrypt.php
T
2026-03-17 16:48:52 +08:00

217 lines
7.3 KiB
PHP
Raw 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
namespace wchat\wx\V3\Libs;
use Exception;
/**
* 微信公众号消息加解密类
* 基于微信官方文档实现:https://developers.weixin.qq.com/doc/oplatform/developers/dev/push/encryption.html
* 适用场景:安全模式(推荐)下的消息接收与回复
*/
class WxMsgCrypt
{
private string $token; // 开发者设置的Token
private string $encodingAesKey; // 消息加解密密钥(43位字符)
private string $appid; // 公众号AppId
private string|false $aesKey; // 解码后的AES密钥(32字节)
private string $iv; // 初始向量(AES密钥的前16字节)
/**
* 构造函数
* @param string $token 开发者设置的Token
* @param string $encodingAesKey 公众号后台的EncodingAESKey43位)
* @param string $appid 公众号AppId
* @throws Exception 当EncodingAESKey格式不正确时抛出异常
*/
public function __construct(string $token, string $encodingAesKey, string $appid)
{
$this->token = $token;
$this->encodingAesKey = $encodingAesKey;
$this->appid = $appid;
// 解码EncodingAESKey:官方文档要求尾部补一个"="后做Base64解码
$key = $encodingAesKey . '=';
$this->aesKey = base64_decode($key);
if (strlen($this->aesKey) != 32) {
throw new Exception('EncodingAESKey 解码后长度必须为32字节,当前长度为:' . strlen($this->aesKey));
}
// AES CBC模式的IV取密钥的前16字节(官方示例代码标准做法)
$this->iv = substr($this->aesKey, 0, 16);
}
/**
* 验证消息签名(用于接收消息时)
* @param string $timestamp URL中的timestamp参数
* @param string $nonce URL中的nonce参数
* @param string $encrypt 消息体中的Encrypt字段(密文)
* @param string $msgSignature URL中的msg_signature参数
* @return bool 签名验证结果
*/
public function verifySignature(string $timestamp, string $nonce, string $encrypt, string $msgSignature): bool
{
$signature = $this->generateSignature($timestamp, $nonce, $encrypt);
return $signature === $msgSignature;
}
/**
* 解密消息(安全模式接收消息时调用)
* @param string $encrypt 消息体中的Encrypt字段(Base64密文)
* @return string 解密后的明文消息(XML或JSON格式)
* @throws Exception 当解密失败或AppId不匹配时抛出异常
*/
public function decryptMsg(string $encrypt): string
{
// 1. Base64解码
$encryptData = base64_decode($encrypt);
if ($encryptData === false) {
throw new Exception('Base64解码失败');
}
// 2. AES解密(CBC模式,PKCS#7填充)
$decrypted = openssl_decrypt($encryptData, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $this->iv);
if ($decrypted === false) {
throw new Exception('AES解密失败');
}
// 3. 去除PKCS#7填充
$result = $this->pkcs7Unpad($decrypted);
// 4. 解析明文结构:16字节随机字符串 + 4字节网络字节序长度 + 消息内容 + AppId
if (strlen($result) < 20) { // 至少需要16+4=20字节
throw new Exception('解密后的数据长度异常');
}
$random = substr($result, 0, 16); // 16字节随机字符串(可忽略)
$msgLen = unpack('N', substr($result, 16, 4))[1]; // 4字节网络字节序长度
$msg = substr($result, 20, $msgLen); // 消息内容
$appid = substr($result, 20 + $msgLen); // 剩余的AppId
// 5. 验证AppId是否匹配
if ($appid !== $this->appid) {
throw new Exception('AppId不匹配,可能被篡改');
}
return $msg;
}
/**
* 加密消息(安全模式回复消息时调用)
* @param string $replyMsg 待回复的明文消息(XML或JSON格式)
* @param int|null $timestamp 时间戳(可空,为空则自动生成)
* @param string|null $nonce 随机字符串(可空,为空则自动生成)
* @return array 包含Encrypt, MsgSignature, TimeStamp, Nonce的关联数组
* @throws Exception 当加密失败时抛出异常
*/
public function encryptMsg(string $replyMsg, ?int $timestamp = null, ?string $nonce = null): array
{
// 1. 生成16字节随机字符串
$random = openssl_random_pseudo_bytes(16);
// 2. 计算消息长度(网络字节序)
$msgLen = pack('N', strlen($replyMsg));
// 3. 构造待加密的字符串:随机字符串(16B) + 消息长度(4B) + 消息内容 + AppId
$plainText = $random . $msgLen . $replyMsg . $this->appid;
// 4. PKCS#7填充(按16字节块)
$padded = $this->pkcs7Pad($plainText);
// 5. AES加密
$encrypted = openssl_encrypt($padded, 'AES-256-CBC', $this->aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $this->iv);
if ($encrypted === false) {
throw new Exception('AES加密失败');
}
// 6. Base64编码得到Encrypt字段
$encrypt = base64_encode($encrypted);
// 7. 生成时间戳和随机数
$timestamp = $timestamp ?: time();
$nonce = $nonce ?: $this->generateNonce();
// 8. 生成消息签名
$msgSignature = $this->generateSignature($timestamp, $nonce, $encrypt);
// 9. 返回加密后的数据包
return [
'Encrypt' => $encrypt,
'MsgSignature' => $msgSignature,
'TimeStamp' => $timestamp,
'Nonce' => $nonce,
];
}
/* -------------------- 内部辅助方法 -------------------- */
/**
* 生成消息签名(使用token、timestamp、nonce、encrypt
* @param string $timestamp 时间戳
* @param string $nonce 随机数
* @param string $encrypt 密文
* @return string 签名字符串
*/
private function generateSignature(string $timestamp, string $nonce, string $encrypt): string
{
$params = [$this->token, $timestamp, $nonce, $encrypt];
sort($params, SORT_STRING); // 字典序排序
$str = implode('', $params); // 拼接字符串
return sha1($str); // SHA1哈希
}
/**
* 生成随机字符串(用于nonce)
* @param int $length 长度
* @return string
*/
private function generateNonce(int $length = 8): string
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$nonce = '';
for ($i = 0; $i < $length; $i++) {
$nonce .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $nonce;
}
/**
* PKCS#7填充(按16字节块)
* @param string $text 待填充的数据
* @return string 填充后的数据
*/
private function pkcs7Pad(string $text): string
{
$blockSize = 16; // AES块大小固定16字节
$padLen = $blockSize - (strlen($text) % $blockSize);
$pad = str_repeat(chr($padLen), $padLen);
return $text . $pad;
}
/**
* 去除PKCS#7填充
* @param string $text 解密后的数据(已去除OPENSSL_ZERO_PADDING的填充)
* @return string 去除填充后的数据
* @throws Exception 当填充格式错误时抛出异常
*/
private function pkcs7Unpad(string $text): string
{
$len = strlen($text);
if ($len == 0) {
return $text;
}
$padLen = ord($text[$len - 1]); // 最后一个字节表示填充的长度
if ($padLen < 1 || $padLen > 16) {
throw new Exception('无效的PKCS#7填充');
}
// 验证填充字节是否一致(简化验证,可根据需要加强)
for ($i = 0; $i < $padLen; $i++) {
if (ord($text[$len - 1 - $i]) != $padLen) {
throw new Exception('PKCS#7填充验证失败');
}
}
return substr($text, 0, $len - $padLen);
}
}