初始化询价智能体新仓:同步本地工程并排除密钥与 .env。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
企微回调加解密(WXBizMsgCrypt 语义)。
|
||||
|
||||
本文件职责:URL 验签、密文解密、回包加密;密钥只从 Settings/环境变量读入。
|
||||
禁止:把 Token/AESKey 写进源码或注释;禁止在本文件发业务消息。
|
||||
算法依据:企业微信官方回调加解密文档(AES-CBC + SHA1 签名)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeComCryptoError(Exception):
|
||||
"""验签或加解密失败;回调应拒绝入队。"""
|
||||
|
||||
|
||||
class WeComCrypto:
|
||||
"""
|
||||
企微消息加解密器。
|
||||
|
||||
参数:
|
||||
token:回调 Token(WECOM_CALLBACK_TOKEN)
|
||||
encoding_aes_key:43 字符 EncodingAESKey(WECOM_ENCODING_AES_KEY)
|
||||
corp_id:企业 CorpId(WECOM_CORP_ID),解密后 receiveid 须匹配
|
||||
线程:无共享可变状态,可多线程复用同一实例。
|
||||
"""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, corp_id: str) -> None:
|
||||
if not token or not encoding_aes_key or not corp_id:
|
||||
raise WeComCryptoError("企微加解密三件套未配置完整(token/aes_key/corp_id)")
|
||||
self._token = token
|
||||
self._corp_id = corp_id
|
||||
# EncodingAESKey 官方为 43 字符,补 '=' 后 base64 得到 32 字节 AES Key
|
||||
try:
|
||||
self._aes_key = base64.b64decode(encoding_aes_key + "=")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WeComCryptoError("EncodingAESKey 非法") from exc
|
||||
if len(self._aes_key) != 32:
|
||||
raise WeComCryptoError("EncodingAESKey 解码后长度必须为 32 字节")
|
||||
|
||||
def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> None:
|
||||
"""
|
||||
校验 msg_signature = sha1(sort(token, timestamp, nonce, encrypt))。
|
||||
|
||||
失败抛 WeComCryptoError;成功无返回值。
|
||||
"""
|
||||
expected = self._sha1_signature(timestamp, nonce, encrypt)
|
||||
if expected != msg_signature:
|
||||
raise WeComCryptoError("msg_signature 校验失败")
|
||||
|
||||
def decrypt(self, encrypt_b64: str) -> str:
|
||||
"""
|
||||
解密 Encrypt 字段,返回明文 XML/JSON 字符串。
|
||||
|
||||
副作用:无外部 I/O。receiveid 必须等于 corp_id,否则视为串包攻击。
|
||||
"""
|
||||
try:
|
||||
cipher_bytes = base64.b64decode(encrypt_b64)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise WeComCryptoError("Encrypt base64 解码失败") from exc
|
||||
|
||||
iv = self._aes_key[:16]
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(self._aes_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
).decryptor()
|
||||
plain = decryptor.update(cipher_bytes) + decryptor.finalize()
|
||||
pad = plain[-1]
|
||||
if isinstance(pad, str): # pragma: no cover
|
||||
pad = ord(pad)
|
||||
content = plain[:-pad]
|
||||
# 16 字节随机 + 4 字节网络序长度 + msg + receiveid
|
||||
msg_len = struct.unpack("!I", content[16:20])[0]
|
||||
xml = content[20 : 20 + msg_len].decode("utf-8")
|
||||
from_receive_id = content[20 + msg_len :].decode("utf-8")
|
||||
if from_receive_id != self._corp_id:
|
||||
raise WeComCryptoError("解密 receiveid 与 CorpId 不匹配")
|
||||
return xml
|
||||
|
||||
def encrypt(self, plain: str) -> str:
|
||||
"""
|
||||
加密明文,返回 base64 Encrypt,供被动回包使用。
|
||||
|
||||
为何仍实现:URL 验证只需解密;部分场景被动回复需加密(本迭代主动出站走 API)。
|
||||
"""
|
||||
random16 = os.urandom(16)
|
||||
text = plain.encode("utf-8")
|
||||
corp = self._corp_id.encode("utf-8")
|
||||
body = random16 + struct.pack("!I", len(text)) + text + corp
|
||||
# PKCS#7 填充到 32 字节块
|
||||
amount = 32 - (len(body) % 32)
|
||||
body = body + bytes([amount] * amount)
|
||||
iv = self._aes_key[:16]
|
||||
encryptor = Cipher(
|
||||
algorithms.AES(self._aes_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
).encryptor()
|
||||
cipher = encryptor.update(body) + encryptor.finalize()
|
||||
return base64.b64encode(cipher).decode("utf-8")
|
||||
|
||||
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
|
||||
"""
|
||||
处理企微「保存 URL」验证:验签并解密 echostr,返回明文给 HTTP 响应体。
|
||||
"""
|
||||
self.verify_signature(msg_signature, timestamp, nonce, echostr)
|
||||
return self.decrypt(echostr)
|
||||
|
||||
def build_encrypted_response(self, plain: str, nonce: str | None = None) -> str:
|
||||
"""
|
||||
构造加密被动回复 XML(当前 echo 主路径走主动发消息,此方法备用)。
|
||||
"""
|
||||
nonce = nonce or str(int(time.time()))
|
||||
timestamp = str(int(time.time()))
|
||||
encrypt = self.encrypt(plain)
|
||||
signature = self._sha1_signature(timestamp, nonce, encrypt)
|
||||
return (
|
||||
"<xml>"
|
||||
f"<Encrypt><![CDATA[{encrypt}]]></Encrypt>"
|
||||
f"<MsgSignature><![CDATA[{signature}]]></MsgSignature>"
|
||||
f"<TimeStamp>{timestamp}</TimeStamp>"
|
||||
f"<Nonce><![CDATA[{nonce}]]></Nonce>"
|
||||
"</xml>"
|
||||
)
|
||||
|
||||
def _sha1_signature(self, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
items = sorted([self._token, timestamp, nonce, encrypt])
|
||||
return hashlib.sha1("".join(items).encode("utf-8")).hexdigest()
|
||||
Reference in New Issue
Block a user