实现了微信公众号全功能渠道插件,包含会话解析、消息收发、富消息适配、生命周期管理、主动探测等核心能力,遵循单真相源设计原则,所有配置与常量统一从manifest.json与_constants.py获取。
264 lines
7.8 KiB
Python
264 lines
7.8 KiB
Python
"""微信公众号 AES-128-CBC 加解密与 SHA1 签名工具模块。
|
||
|
||
提供微信公众号消息加解密(明文/兼容/安全三种模式)与 Webhook 签名校验。
|
||
|
||
密钥派生(功能点清单 §2.1):
|
||
- ``EncodingAESKey``(43 位 base64)+ ``"="`` → base64 解码 → 32 字节
|
||
- AES 密钥 = 前 16 字节(AES-128)
|
||
- IV = 前 16 字节(与密钥相同,CBC 模式)
|
||
|
||
明文结构(解密后):
|
||
- 16 字节随机字符串
|
||
- 4 字节消息长度(大端 uint32)
|
||
- XML 消息内容
|
||
- AppID
|
||
|
||
仅依赖标准库 + cryptography 库 + contract.errors,不 import 框架层。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import os
|
||
import struct
|
||
import xml.etree.ElementTree as ET
|
||
|
||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||
from cryptography.hazmat.primitives.padding import PKCS7
|
||
|
||
from yuxi.channels.contract.errors import ValidationError
|
||
|
||
from ._constants import ENCODING_AES_KEY_LENGTH
|
||
|
||
# AES-128 块大小(比特),PKCS7 填充参数
|
||
_AES_BLOCK_BITS = 128
|
||
|
||
# 微信消息体明文各段长度
|
||
_RANDOM_BYTES_LEN = 16
|
||
_MSG_LEN_BYTES = 4
|
||
|
||
|
||
def derive_aes_key(encoding_aes_key: str) -> bytes:
|
||
"""从 EncodingAESKey 派生 16 字节 AES 密钥。
|
||
|
||
流程:43 位 base64 + ``"="`` → base64 解码 → 32 字节 → 前 16 字节作为 AES 密钥。
|
||
|
||
Args:
|
||
encoding_aes_key: 43 位 EncodingAESKey。
|
||
|
||
Returns:
|
||
16 字节 AES 密钥。
|
||
|
||
Raises:
|
||
ValidationError: EncodingAESKey 长度不为 43 或 base64 解码失败。
|
||
"""
|
||
if not encoding_aes_key or len(encoding_aes_key) != ENCODING_AES_KEY_LENGTH:
|
||
raise ValidationError(
|
||
field="encoding_aes_key",
|
||
message=f"encoding_aes_key_must_be_{ENCODING_AES_KEY_LENGTH}_chars",
|
||
)
|
||
try:
|
||
decoded = base64.b64decode(encoding_aes_key + "=")
|
||
except Exception as exc:
|
||
raise ValidationError(
|
||
field="encoding_aes_key",
|
||
message="encoding_aes_key_base64_decode_failed",
|
||
) from exc
|
||
if len(decoded) < 16:
|
||
raise ValidationError(
|
||
field="encoding_aes_key",
|
||
message="encoding_aes_key_decoded_too_short",
|
||
)
|
||
return decoded[:16]
|
||
|
||
|
||
def encrypt_message(plaintext_xml: str, encoding_aes_key: str, app_id: str) -> str:
|
||
"""AES-128-CBC 加密消息,返回 base64 编码密文。
|
||
|
||
明文结构:16 字节随机串 + 4 字节消息长度(大端)+ XML + AppID,PKCS7 填充。
|
||
|
||
Args:
|
||
plaintext_xml: 待加密的 XML 字符串。
|
||
encoding_aes_key: 43 位 EncodingAESKey。
|
||
app_id: 公众号 AppID(用于校验)。
|
||
|
||
Returns:
|
||
base64 编码的密文字符串。
|
||
"""
|
||
key = derive_aes_key(encoding_aes_key)
|
||
iv = key # IV 与密钥相同(微信协议约定)
|
||
random_bytes = os.urandom(_RANDOM_BYTES_LEN)
|
||
msg_bytes = plaintext_xml.encode("utf-8")
|
||
appid_bytes = app_id.encode("utf-8")
|
||
msg_len = struct.pack(">I", len(msg_bytes))
|
||
plaintext = random_bytes + msg_len + msg_bytes + appid_bytes
|
||
|
||
padder = PKCS7(_AES_BLOCK_BITS).padder()
|
||
padded = padder.update(plaintext) + padder.finalize()
|
||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
||
encryptor = cipher.encryptor()
|
||
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
||
return base64.b64encode(ciphertext).decode("ascii")
|
||
|
||
|
||
def decrypt_message(ciphertext_b64: str, encoding_aes_key: str, expected_app_id: str) -> str:
|
||
"""AES-128-CBC 解密消息,返回 XML 字符串。
|
||
|
||
流程:base64 解码 → AES-128-CBC 解密 → 去 PKCS7 填充 → 校验 AppID → 提取 XML。
|
||
|
||
Args:
|
||
ciphertext_b64: base64 编码的密文。
|
||
encoding_aes_key: 43 位 EncodingAESKey。
|
||
expected_app_id: 期望的 AppID(用于校验)。
|
||
|
||
Returns:
|
||
解密后的 XML 字符串。
|
||
|
||
Raises:
|
||
ValidationError: 解密失败、AppID 不匹配、消息长度异常。
|
||
"""
|
||
key = derive_aes_key(encoding_aes_key)
|
||
iv = key
|
||
try:
|
||
ciphertext = base64.b64decode(ciphertext_b64)
|
||
except Exception as exc:
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
) from exc
|
||
|
||
try:
|
||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
||
decryptor = cipher.decryptor()
|
||
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
||
unpadder = PKCS7(_AES_BLOCK_BITS).unpadder()
|
||
plaintext = unpadder.update(padded) + unpadder.finalize()
|
||
except Exception as exc:
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
) from exc
|
||
|
||
if len(plaintext) < _RANDOM_BYTES_LEN + _MSG_LEN_BYTES:
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
)
|
||
|
||
msg_len = struct.unpack(">I", plaintext[_RANDOM_BYTES_LEN : _RANDOM_BYTES_LEN + _MSG_LEN_BYTES])[0]
|
||
msg_start = _RANDOM_BYTES_LEN + _MSG_LEN_BYTES
|
||
msg_end = msg_start + msg_len
|
||
if msg_end > len(plaintext):
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
)
|
||
|
||
msg_bytes = plaintext[msg_start:msg_end]
|
||
appid_bytes = plaintext[msg_end:]
|
||
try:
|
||
app_id = appid_bytes.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
) from exc
|
||
|
||
if app_id != expected_app_id:
|
||
raise ValidationError(
|
||
field="AppID",
|
||
message="appid_mismatch",
|
||
)
|
||
|
||
try:
|
||
return msg_bytes.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
raise ValidationError(
|
||
field="Encrypt",
|
||
message="decrypt_failed",
|
||
) from exc
|
||
|
||
|
||
def compute_signature(token: str, timestamp: str, nonce: str, *extra: str) -> str:
|
||
"""计算微信 Webhook SHA1 签名。
|
||
|
||
公式:``SHA1(sort([token, timestamp, nonce, *extra])).hexdigest()``
|
||
|
||
Args:
|
||
token: Webhook 签名校验令牌。
|
||
timestamp: 时间戳。
|
||
nonce: 随机数。
|
||
*extra: 额外参与签名的字段(如加密消息体 ``encrypt``)。
|
||
|
||
Returns:
|
||
40 位十六进制 SHA1 签名。
|
||
"""
|
||
parts = sorted([token, timestamp, nonce, *extra])
|
||
raw = "".join(parts).encode("utf-8")
|
||
return hashlib.sha1(raw).hexdigest()
|
||
|
||
|
||
def verify_signature(
|
||
signature: str,
|
||
token: str,
|
||
timestamp: str,
|
||
nonce: str,
|
||
*extra: str,
|
||
) -> bool:
|
||
"""校验微信 Webhook 签名。
|
||
|
||
Args:
|
||
signature: 待校验的签名。
|
||
token: Webhook 签名校验令牌。
|
||
timestamp: 时间戳。
|
||
nonce: 随机数。
|
||
*extra: 额外参与签名的字段。
|
||
|
||
Returns:
|
||
签名匹配返回 True,否则 False。
|
||
"""
|
||
expected = compute_signature(token, timestamp, nonce, *extra)
|
||
return _constant_time_eq(signature, expected)
|
||
|
||
|
||
def extract_encrypt(xml_text: str) -> str | None:
|
||
"""从 XML 中提取 ``<Encrypt>`` 字段内容。
|
||
|
||
兼容/安全模式下,入站 XML 含 ``<Encrypt>`` 字段(base64 密文)。
|
||
|
||
Args:
|
||
xml_text: 原始 XML 字符串。
|
||
|
||
Returns:
|
||
Encrypt 字段内容;不存在返回 None。
|
||
"""
|
||
try:
|
||
root = ET.fromstring(xml_text)
|
||
except ET.ParseError:
|
||
return None
|
||
encrypt_node = root.find("Encrypt")
|
||
if encrypt_node is None or encrypt_node.text is None:
|
||
return None
|
||
return encrypt_node.text
|
||
|
||
|
||
def _constant_time_eq(a: str, b: str) -> bool:
|
||
"""恒定时间字符串比较,避免时序攻击。"""
|
||
if len(a) != len(b):
|
||
return False
|
||
result = 0
|
||
for x, y in zip(a, b, strict=False):
|
||
result |= ord(x) ^ ord(y)
|
||
return result == 0
|
||
|
||
|
||
__all__ = [
|
||
"derive_aes_key",
|
||
"encrypt_message",
|
||
"decrypt_message",
|
||
"compute_signature",
|
||
"verify_signature",
|
||
"extract_encrypt",
|
||
]
|