# 微信消息推送 — 安全模式(AES 加解密)说明 > 校验依据:https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html ## 当前状态 - GET 验签:已通过,不受加密模式影响 - POST 消息接收:选择了「安全模式」,微信推送的消息体为 AES 加密,需要解密后才能读取 ## 需要的配置 在 `apps/backend/.env.local` 中添加: ```env # 微信后台「消息推送」页面的 EncodingAESKey(43位字符串) WX_ENCODING_AES_KEY=你在微信后台生成的那个43位字符串 # 小程序的 AppID WX_APP_ID=你的小程序AppID ``` ## 安全模式与明文模式的关键区别 安全模式下,微信 POST 推送的 URL 参数会多出两个字段: | 参数 | 说明 | |------|------| | `signature` | 普通签名(token + timestamp + nonce),仅用于明文模式 | | `msg_signature` | 消息签名(token + timestamp + nonce + Encrypt),安全模式必须用这个验签 | | `encrypt_type` | 值为 `aes`,表示安全模式 | | `timestamp` | 时间戳 | | `nonce` | 随机数 | **重要:安全模式下验签必须使用 `msg_signature`,不要使用 `signature`!** 验签算法:将 token、timestamp、nonce、Encrypt(包体内的字段)四个参数字典序排序后拼接,做 SHA1,与 `msg_signature` 比对。 ## 解密原理 安全模式下,微信 POST 推送的 JSON 结构为: ```json { "Encrypt": "加密后的密文字符串" } ``` 解密流程: 1. 从请求体取出 `Encrypt` 字段 2. AESKey = Base64_Decode(EncodingAESKey + "="),得到 32 字节 AES 密钥 3. 将 Encrypt 密文进行 Base64 解码,得到 TmpMsg 4. 用 AESKey 对 TmpMsg 进行 AES-256-CBC 解密(IV 取 AESKey 前 16 字节),去除 PKCS#7 填充(K=32) 5. 解密后的明文格式:`random(16B) + msg_len(4B) + msg + appid` - random(16B):16 字节随机字符串 - msg_len:消息长度,4 字节网络字节序(大端) - msg:解密后的明文消息 - appid:小程序 AppID,需验证是否与自身一致 ## 回包加密(安全模式下需要加密回包) 如果需要回复非空内容(非 `success`),安全模式下回包也需要加密: 1. 构造 FullStr = random(16B) + msg_len(4B) + msg + appid 2. 用 AESKey 进行 AES-256-CBC 加密,PKCS#7 填充(K=32) 3. Base64 编码得到 Encrypt 4. 生成 MsgSignature:将 token、TimeStamp、Nonce、Encrypt 四个参数字典序排序拼接后 SHA1 5. 回包格式(JSON): ```json { "Encrypt": "加密后的密文", "MsgSignature": "签名", "TimeStamp": 时间戳, "Nonce": "随机数" } ``` > 如果只需回复 `success`,无需加密,直接返回纯文本 `success` 即可。 ## 代码改动 ### 方案 A:使用微信官方示例代码(推荐) 微信官方提供了多种语言的加解密示例代码(PHP、Java、C++、Python、C#), 建议优先使用官方示例:https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html (页面底部「示例下载」链接) 安装依赖: ```bash pip install pycryptodome # AES 加解密 ``` ### 方案 B:手动实现解密工具 在 `apps/backend/app/utils/` 下新建 `wx_crypto.py`: ```python """ 微信消息加解密工具 校验依据: https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html PKCS#7 填充说明(微信官方规范): - K 为秘钥字节数(采用 32) - Buf 为待加密的内容,N 为其字节数 - Buf 需要被填充为 K 的整数倍 - 在 Buf 的尾部填充 (K - N%K) 个字节,每个字节的内容是 (K - N%K) """ import base64 import hashlib import struct from Crypto.Cipher import AES BLOCK_SIZE = 32 # 微信规范:PKCS#7 填充使用 K=32 class WXBizMsgCrypt: """微信消息加解密""" def __init__(self, token: str, encoding_aes_key: str, app_id: str): self.token = token self.app_id = app_id # AESKey = Base64_Decode(EncodingAESKey + "=") self.aes_key = base64.b64decode(encoding_aes_key + "=") self.iv = self.aes_key[:16] # 初始向量取密钥前 16 字节 def check_msg_signature( self, msg_signature: str, timestamp: str, nonce: str, encrypt: str ) -> bool: """ 安全模式验签。 注意:安全模式下必须用 msg_signature 验签,参与排序的是四个参数: token、timestamp、nonce、Encrypt(包体内的字段) """ items = sorted([self.token, timestamp, nonce, encrypt]) hash_str = hashlib.sha1("".join(items).encode("utf-8")).hexdigest() return hash_str == msg_signature def decrypt(self, encrypt_text: str) -> str: """ 解密微信推送的加密消息。 返回解密后的消息明文(JSON 字符串)。 """ cipher = AES.new(self.aes_key, AES.MODE_CBC, self.iv) decrypted = cipher.decrypt(base64.b64decode(encrypt_text)) # 去除 PKCS#7 填充(K=32) pad_len = decrypted[-1] if pad_len < 1 or pad_len > BLOCK_SIZE: raise ValueError(f"无效的 PKCS#7 填充: {pad_len}") content = decrypted[:-pad_len] # FullStr = random(16B) + msg_len(4B) + msg + appid msg_len = struct.unpack("!I", content[16:20])[0] msg = content[20 : 20 + msg_len].decode("utf-8") from_app_id = content[20 + msg_len :].decode("utf-8") # 校验 AppID if from_app_id != self.app_id: raise ValueError( f"AppID 不匹配: 期望 {self.app_id}, 实际 {from_app_id}" ) return msg def encrypt(self, msg: str) -> str: """ 加密回包消息(安全模式下回复非 success 内容时需要)。 返回 Base64 编码的密文。 """ import os # 构造 FullStr = random(16B) + msg_len(4B) + msg + appid msg_bytes = msg.encode("utf-8") app_id_bytes = self.app_id.encode("utf-8") full_str = ( os.urandom(16) + struct.pack("!I", len(msg_bytes)) + msg_bytes + app_id_bytes ) # PKCS#7 填充(K=32) pad_len = BLOCK_SIZE - (len(full_str) % BLOCK_SIZE) full_str += bytes([pad_len] * pad_len) cipher = AES.new(self.aes_key, AES.MODE_CBC, self.iv) encrypted = cipher.encrypt(full_str) return base64.b64encode(encrypted).decode("utf-8") def generate_msg_signature( self, timestamp: str, nonce: str, encrypt: str ) -> str: """生成回包的 MsgSignature""" items = sorted([self.token, timestamp, nonce, encrypt]) return hashlib.sha1("".join(items).encode("utf-8")).hexdigest() ``` ### 改动 `wx_callback.py` 的 POST 处理 ```python # 在文件顶部新增导入 import json from app.utils.wx_crypto import WXBizMsgCrypt from app.config import get # 初始化解密器(模块级别) wx_crypt = WXBizMsgCrypt( token=get("WX_CALLBACK_TOKEN", ""), encoding_aes_key=get("WX_ENCODING_AES_KEY", ""), app_id=get("WX_APP_ID", ""), ) @router.post("/callback") async def receive_message( request: Request, msg_signature: str = Query("", alias="msg_signature"), signature: str = Query(""), timestamp: str = Query(""), nonce: str = Query(""), encrypt_type: str = Query(""), ): body = await request.body() raw = json.loads(body) # 安全模式:用 msg_signature 验签(四参数:token + timestamp + nonce + Encrypt) if encrypt_type == "aes" and "Encrypt" in raw: if not wx_crypt.check_msg_signature( msg_signature, timestamp, nonce, raw["Encrypt"] ): logger.warning("安全模式消息推送验签失败") return Response(content="signature mismatch", status_code=403) decrypted_str = wx_crypt.decrypt(raw["Encrypt"]) data = json.loads(decrypted_str) else: # 明文模式 / 兼容模式:用 signature 验签(三参数) if not _check_signature(signature, timestamp, nonce): logger.warning("明文模式消息推送验签失败") return Response(content="signature mismatch", status_code=403) data = raw logger.info( "收到微信推送: MsgType=%s, Event=%s", data.get("MsgType", "?"), data.get("Event", "?"), ) # TODO: 根据 MsgType/Event 分发处理 # 回复 success 无需加密 return Response(content="success", media_type="text/plain") ``` ## 依赖安装 ```bash pip install pycryptodome ``` 注意:是 `pycryptodome` 不是 `pycrypto`(后者已废弃)。 ## 测试方法 部署后可以用微信开发者工具发送客服消息,或者关注/取关小程序触发事件推送, 查看后端日志确认消息是否正确解密: ```bash # 在 Windows 服务器上查看后端日志 # 应该能看到 "收到微信推送: MsgType=event, Event=subscribe" 之类的输出 ``` 微信也提供了调试工具,可在消息推送配置页面使用「请求构造」和「调试工具」进行测试。 ## 注意事项 - `EncodingAESKey` 必须和微信后台配置的完全一致,一个字符都不能错 - 如果后续在微信后台重新生成了 `EncodingAESKey`,服务器端也要同步更新并重启 - GET 验签逻辑不需要改动,安全模式只影响 POST 消息体 - PKCS#7 填充的 K 值为 32(不是常见的 16),这是微信的特殊规范 - 安全模式下验签用 `msg_signature`(四参数),不要用 `signature`(三参数)