4、消息加解密:AES加密原理、消息体加解密模式、安全模式的配置与调试

好,咱们进入第四讲。消息加解密这块,说实在的,是很多开发者第一次接触微信生态时最头疼的部分。我当年第一次对接时,看着官方文档里那一堆 EncodingAESKey、消息体签名、随机数,说实话,头都是大的。

但你别怕。今天咱们把 AES 加密原理、消息体加解密模式、还有安全模式的配置与调试,一层层剥开来讲。我保证,讲完之后你再看官方文档,会觉得很亲切。

4.1 AES加密原理:别被“高级加密标准”吓到

AES,全称 Advanced Encryption Standard,中文叫高级加密标准。说白了,就是一种对称加密算法。什么叫对称加密?就是加密和解密用的是同一把钥匙。

你想想看,微信服务器给你发消息,用一把钥匙加密。你收到后,用同一把钥匙解密。这就叫对称。

微信用的是 AES-256-CBC 模式。这里有两个关键点:

  • AES-256:密钥长度是 256 位,也就是 32 个字节。这是目前最安全的级别之一。
  • CBC 模式:Cipher Block Chaining,密码分组链接模式。每个明文块在加密前,会先和前一个密文块做异或运算。

核心要点:微信的 EncodingAESKey 经过 Base64 解码后,得到的就是 43 字节的数据。前 32 字节是 AES 密钥,后 11 字节是固定的,可以忽略。

为什么会这样设计?我个人的理解是,微信想用一个字符串(EncodingAESKey)同时承载密钥和校验信息,方便开发者配置。嗯,这里要注意,你拿到的 EncodingAESKey 是 Base64 编码后的,千万别直接当密钥用。

4.2 消息体加解密模式:三种模式怎么选?

微信公众平台提供了三种消息加解密模式。我在项目中遇到过不少团队,一开始图省事选了明文模式,结果上线后被安全团队打回来重做。所以,我建议你从一开始就想清楚。

模式 说明 适用场景
明文模式 消息体不加密,直接传输 XML 仅用于开发调试,绝对不要用于生产环境
兼容模式 消息体同时包含明文和密文 过渡阶段,新旧系统并行时使用
安全模式 消息体完全加密,只传输密文 生产环境推荐,安全性最高

我个人习惯,从一开始就直接用安全模式。为什么?因为兼容模式虽然看起来“友好”,但实际上你要同时处理两套逻辑,反而容易出 bug。我曾经见过一个项目,兼容模式下明文和密文解析不一致,导致用户消息丢失,排查了整整两天。

避坑指南:我曾经在兼容模式下踩过一个坑——微信服务器发来的消息,Encrypt 标签里的内容和明文部分的 Content 不一致。后来发现是签名校验没通过,但兼容模式不会报错,只是默默返回错误。所以,如果你用兼容模式,一定要自己加一层校验。

4.3 安全模式的配置与调试:手把手教你跑通

好,咱们进入实战环节。安全模式的配置,说白了就三步:

  1. 在微信公众平台后台设置 EncodingAESKey 和 Token
  2. 在服务器代码中实现解密逻辑
  3. 验证签名并返回响应

咱们重点讲第二步和第三步。

4.3.1 解密流程拆解

微信服务器发来的密文消息,结构是这样的:

<xml>
  <ToUserName><![CDATA[gh_xxx]]></ToUserName>
  <Encrypt><![CDATA[密文内容]]></Encrypt>
</xml>

解密时,你需要做这几件事:

  • 从 XML 中提取 Encrypt 标签的内容
  • 用 EncodingAESKey 解码后的密钥进行 AES-256-CBC 解密
  • 解密后的数据包含:随机数(16字节)+ 明文消息 + 用户ID
  • 验证消息体签名

嗯,这里要注意,解密后的数据格式是固定的:

[16字节随机数][明文消息][用户ID]

用户ID 的长度是固定的,你可以通过解析最后几位来提取。我刚开始写的时候,直接用字符串截取,结果发现用户ID 长度不对。后来才意识到,用户ID 是变长的,需要用 strlen 或者类似的方法先拿到长度。

4.3.2 代码示例(PHP 版)

我习惯用 PHP 做演示,因为微信官方 SDK 最早就是 PHP 写的。但原理是通用的,你换成 Java、Python、Go 都一样。

// 解密函数
function decryptMsg($encryptedMsg, $encodingAesKey, $token, $appId) {
    // 1. 解码 EncodingAESKey
    $aesKey = base64_decode($encodingAesKey . "=");
    
    // 2. 提取密文
    $xml = simplexml_load_string($encryptedMsg, 'SimpleXMLElement', LIBXML_NOCDATA);
    $encrypt = (string)$xml->Encrypt;
    
    // 3. Base64 解码密文
    $ciphertext = base64_decode($encrypt);
    
    // 4. AES-256-CBC 解密
    $iv = substr($aesKey, 0, 16);
    $decrypted = openssl_decrypt($ciphertext, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
    
    // 5. 去除 PKCS7 填充
    $pad = ord(substr($decrypted, -1));
    $decrypted = substr($decrypted, 0, -$pad);
    
    // 6. 提取随机数、明文和用户ID
    $random = substr($decrypted, 0, 16);
    $content = substr($decrypted, 16);
    $xmlLen = unpack("N", substr($content, 0, 4))[1];
    $xmlContent = substr($content, 4, $xmlLen);
    $fromAppId = substr($content, 4 + $xmlLen);
    
    // 7. 验证 AppId
    if ($fromAppId !== $appId) {
        throw new Exception("AppId 不匹配");
    }
    
    return $xmlContent;
}

重要提醒:上面的代码中,第 5 步的 PKCS7 填充去除很容易被忽略。如果你解密出来的数据末尾有多余的乱码字符,十有八九是填充没处理好。我曾经因为这个 bug,排查了整整一个下午。

4.3.3 签名验证:别漏了这一步

解密完成后,你还需要验证消息体签名。微信服务器会在请求中带三个参数:signaturetimestampnonce。你需要用 Token、timestamp、nonce 和密文拼接后做 SHA1 哈希,然后和 signature 对比。

function checkSignature($token, $timestamp, $nonce, $encrypt, $signature) {
    $tmpArr = array($token, $timestamp, $nonce, $encrypt);
    sort($tmpArr, SORT_STRING);
    $tmpStr = implode($tmpArr);
    $tmpStr = sha1($tmpStr);
    
    return $tmpStr === $signature;
}

你想想看,如果签名验证不通过,说明消息可能被篡改过。这时候直接返回错误,不要继续处理。我见过有些同学为了省事,跳过了签名验证,结果被恶意攻击者伪造消息,造成了不小的损失。

4.4 调试技巧:让加解密不再玄学

调试加解密,最怕的就是“解密出来一堆乱码”。我分享几个实用技巧:

  • 打印每一步的中间结果:把 Base64 解码后的密钥、IV、密文都打印出来,和官方文档对比。
  • 用官方提供的在线工具验证:微信公众平台后台有“消息加解密验证”工具,把你的 EncodingAESKey 和密文贴进去,看能不能解出来。
  • 注意字符编码:微信的消息体是 UTF-8 编码的。如果你在解密后转成了 GBK 或其他编码,一定会出问题。

个人经验:我曾经在调试时,发现解密出来的 XML 里多了一个不可见字符。排查了半天,发现是 PKCS7 填充的最后一个字节值不对。后来我写了一个单元测试,专门测试各种长度的消息,确保填充逻辑万无一失。

好了,这一讲的内容就到这里。消息加解密虽然看起来复杂,但只要你理解了 AES-256-CBC 的原理,再按照流程一步步来,其实并不难。下一讲,咱们聊聊如何搭建一个高可用的智能客服后端,敬请期待。