ForcePilot/backend/package/yuxi/channels/plugins/wechat_mp/crypto.py

264 lines
7.8 KiB
Python
Raw Normal View History

"""微信公众号 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 + AppIDPKCS7 填充
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",
]