ForcePilot/backend/package/yuxi/channels/plugins/wecom/signature.py
Kris 28d2c19f23 feat(wecom): 新增企业微信渠道插件完整实现
实现企业微信全功能渠道插件,包含适配器、客户端、签名校验、模板卡片构建等模块,完成从事件接收、会话解析到消息发送的完整流程支持,包含配置校验、错误翻译、探测能力与生命周期管理。
2026-07-08 22:58:25 +08:00

282 lines
9.2 KiB
Python
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.

"""企业微信 Webhook 签名校验与消息解密模块。
提供以下能力:
- ``verify_webhook_signature``: 校验 Webhook 签名SHA1 + 恒定时间比对)。
- ``is_timestamp_valid``: 校验时间戳是否在允许的时钟偏移窗口内(防重放)。
- ``decrypt_event``: AES-CBC 解密企业微信回调加密内容。
- ``extract_echostr``: 识别 URL 验证 echostr 请求。
企业微信回调加密算法(与飞书不同):
- 签名:``SHA1(token + timestamp + nonce + encrypt_msg)``(企业微信用 SHA1
- 密钥派生EncodingAESKey43 位Base64 解码后补 ``=`` 得到 32 字节 AES key
- 加密结构Base64(16 字节随机串 + 4 字节消息长度(大端) + 消息体 + CorpID)
- 填充PKCS7块大小 32 字节(企业微信自定义,非标准 16 字节)
依赖方向:仅依赖标准库、``cryptography``(可选)与 ``yuxi.channels.contract``
错误类型,不污染框架层。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import struct
import time
import xml.etree.ElementTree as ET
from typing import Any
from yuxi.channels.contract.errors import DependencyError, ValidationError
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_HAS_CRYPTOGRAPHY = True
except ImportError:
_HAS_CRYPTOGRAPHY = False
# 企业微信 PKCS7 填充块大小32 字节,非标准 AES 16 字节)
_WECOM_BLOCK_SIZE = 32
def verify_webhook_signature(
token: str,
timestamp: str,
nonce: str,
encrypt_msg: str,
msg_signature: str,
) -> bool:
"""校验企业微信 Webhook 签名。
签名算法:``SHA1(token + timestamp + nonce + encrypt_msg)``
使用 ``hmac.compare_digest`` 进行恒定时间比对以防止时序攻击。
Args:
token: 配置的回调签名 Token。
timestamp: 时间戳字符串。
nonce: 随机字符串。
encrypt_msg: 加密消息体Encrypt 字段值)。
msg_signature: 请求查询参数中的签名(十六进制小写)。
Returns:
签名校验结果,``True`` 表示通过。**不抛异常**:输入类型异常或
编码失败时返回 ``False``。
"""
try:
parts = sorted([token, timestamp, nonce, encrypt_msg])
raw = "".join(parts).encode("utf-8")
expected = hashlib.sha1(raw).hexdigest()
except (TypeError, UnicodeEncodeError, AttributeError):
return False
return hmac.compare_digest(expected, msg_signature)
def is_timestamp_valid(timestamp: str, max_skew_seconds: int = 300) -> bool:
"""校验时间戳是否在允许的时钟偏移窗口内(防重放)。
Args:
timestamp: 时间戳字符串(秒级)。
max_skew_seconds: 允许的最大时间偏差(秒),默认 3005 分钟)。
Returns:
``True`` 表示时间戳有效。解析失败或超出窗口返回 ``False``
**不抛异常**。
"""
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
skew = abs(int(time.time()) - ts)
return skew <= max_skew_seconds
def _decode_aes_key(encoding_aes_key: str) -> bytes:
"""将 43 位 EncodingAESKey Base64 解码为 32 字节 AES 密钥。
企业微信 EncodingAESKey 为 43 位 Base64 字符串,需在末尾补 ``=``
后 Base64 解码得到 32 字节密钥。
"""
return base64.b64decode(encoding_aes_key + "=")
def decrypt_event(
encrypt_payload: str,
encoding_aes_key: str,
corp_id: str,
) -> dict[str, Any]:
"""AES-CBC 解密企业微信回调加密内容。
解密流程:
1. EncodingAESKey43 位Base64 解码为 32 字节 AES key
2. 加密内容 Base64 解码
3. AES-CBC 解密IV 取前 16 字节PKCS7 块大小 32
4. 去除前 16 字节随机串 + 4 字节消息长度
5. 提取消息体 XML 与 CorpID校验 CorpID 一致
6. XML 解析为 dict
Args:
encrypt_payload: Base64 编码的加密内容字符串。
encoding_aes_key: 配置的 EncodingAESKey43 位)。
corp_id: 企业微信企业 ID用于解密后校验。
Returns:
解密后的 XML dict。
Raises:
DependencyError: ``cryptography`` 依赖不可用。
ValidationError: 解密失败Base64 解码失败、CorpID 不匹配、
XML 解析失败等),``field`` 为 ``encrypt_payload``。
"""
if not _HAS_CRYPTOGRAPHY:
raise DependencyError(
dep="cryptography",
cause=ImportError("cryptography package is required for event decryption"),
)
try:
key = _decode_aes_key(encoding_aes_key)
encrypted = base64.b64decode(encrypt_payload)
iv = encrypted[:16]
ciphertext = encrypted[16:]
cipher = Cipher(
algorithms.AES(key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
# 企业微信使用 PKCS7 块大小 32非标准 16手动去填充
pad_len = padded[-1]
if pad_len < 1 or pad_len > _WECOM_BLOCK_SIZE:
raise ValueError(f"invalid pad length: {pad_len}")
content = padded[:-pad_len]
# 去除 16 字节随机串 + 4 字节消息长度(大端)
if len(content) < 20:
raise ValueError("decrypted content too short")
msg_len = struct.unpack(">I", content[16:20])[0]
if 20 + msg_len + len(corp_id) != len(content):
raise ValueError("message length mismatch")
msg = content[20 : 20 + msg_len]
from_corp_id = content[20 + msg_len :].decode("utf-8")
if from_corp_id != corp_id:
raise ValueError(f"CorpID mismatch: expected {corp_id}, got {from_corp_id}")
xml_str = msg.decode("utf-8")
return _xml_to_dict(xml_str)
except Exception as exc:
if isinstance(exc, ValidationError):
raise
raise ValidationError(
field="encrypt_payload",
message="decrypt_failed",
) from exc
def decrypt_echostr(
echostr: str,
encoding_aes_key: str,
corp_id: str,
) -> str:
"""解密 URL 验证 echostr。
企业微信注册回调 URL 时发送 GET 请求含 ``echostr`` 参数,需解密后
原样返回完成握手。
Args:
echostr: 加密的 echostr 字符串。
encoding_aes_key: 配置的 EncodingAESKey43 位)。
corp_id: 企业微信企业 ID。
Returns:
解密后的 echostr 字符串。
Raises:
DependencyError: ``cryptography`` 依赖不可用。
ValidationError: 解密失败。
"""
if not _HAS_CRYPTOGRAPHY:
raise DependencyError(
dep="cryptography",
cause=ImportError("cryptography package is required for echostr decryption"),
)
try:
key = _decode_aes_key(encoding_aes_key)
encrypted = base64.b64decode(echostr)
iv = encrypted[:16]
ciphertext = encrypted[16:]
cipher = Cipher(
algorithms.AES(key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
pad_len = padded[-1]
if pad_len < 1 or pad_len > _WECOM_BLOCK_SIZE:
raise ValueError(f"invalid pad length: {pad_len}")
content = padded[:-pad_len]
if len(content) < 20:
raise ValueError("decrypted content too short")
msg_len = struct.unpack(">I", content[16:20])[0]
if 20 + msg_len + len(corp_id) != len(content):
raise ValueError("message length mismatch")
msg = content[20 : 20 + msg_len]
from_corp_id = content[20 + msg_len :].decode("utf-8")
if from_corp_id != corp_id:
raise ValueError(f"CorpID mismatch: expected {corp_id}, got {from_corp_id}")
return msg.decode("utf-8")
except Exception as exc:
raise ValidationError(
field="echostr",
message="decrypt_failed",
) from exc
def _xml_to_dict(xml_str: str) -> dict[str, Any]:
"""将企业微信 XML 消息解析为 dict。
企业微信回调消息为 XML 格式,根元素为 ``<xml>``,子元素为消息字段。
"""
try:
root = ET.fromstring(xml_str)
except ET.ParseError as exc:
raise ValidationError(
field="xml",
message="parse_failed",
) from exc
result: dict[str, Any] = {}
for child in root:
result[child.tag] = child.text or ""
return result
def extract_echostr(payload: dict[str, Any]) -> str | None:
"""识别 URL 验证 echostr 请求。
企业微信在配置回调 URL 时发送 GET 请求含 ``echostr`` 参数,本函数
从查询参数中提取 echostr 值。
Args:
payload: Webhook 请求参数 dict含 query params
Returns:
echostr 字符串;若不含则返回 ``None``。
"""
echostr = payload.get("echostr")
if isinstance(echostr, str) and echostr:
return echostr
return None