Files
wecom-ai-assistant/backend/app/services/wecom_crypto.py
2026-02-05 16:36:32 +08:00

120 lines
3.9 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.
"""企业微信回调加解密与验签(与企微文档一致)。"""
import base64
import hashlib
import struct
import xml.etree.ElementTree as ET
from typing import Tuple
from Crypto.Cipher import AES
from app.config import settings
def _sha1(s: str) -> str:
return hashlib.sha1(s.encode()).hexdigest()
def _check_signature(signature: str, timestamp: str, nonce: str, echostr_or_encrypt: str) -> bool:
token = settings.wecom_token
lst = [token, timestamp, nonce, echostr_or_encrypt]
lst.sort()
return _sha1("".join(lst)) == signature
def _aes_key() -> bytes:
key_b64 = settings.wecom_encoding_aes_key + "="
return base64.b64decode(key_b64)[:32]
def decrypt(encrypt: str) -> str:
"""解密企微回调密文echostr 或 Encrypt 节点内容)。"""
key = _aes_key()
iv = key[:16]
raw = base64.b64decode(encrypt)
cipher = AES.new(key, AES.MODE_CBC, iv)
dec = cipher.decrypt(raw)
# 16 随机字节 + 4 字节长度(big-endian) + 消息 + corpid先按长度取消息避免 padding 差异
msg_len = struct.unpack(">I", dec[16:20])[0]
return dec[20 : 20 + msg_len].decode("utf-8")
def encrypt(plain: str) -> str:
"""加密回复内容(明文为 XML 或文本)。"""
import os
key = _aes_key()
iv = key[:16]
corpid = settings.wecom_corp_id or "placeholder"
msg = plain.encode("utf-8")
msg_len = struct.pack(">I", len(msg))
rand = os.urandom(16)
to_enc = rand + msg_len + msg + corpid.encode("utf-8")
from Crypto.Util.Padding import pad
to_enc = pad(to_enc, 16)
cipher = AES.new(key, AES.MODE_CBC, iv)
enc = cipher.encrypt(to_enc)
return base64.b64encode(enc).decode("ascii")
def verify_signature(msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
"""校验签名GET 或 POST 的 Encrypt"""
return _check_signature(msg_signature, timestamp, nonce, encrypt)
def verify_and_decrypt_echostr(msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
"""GET 校验:验签并解密 echostr返回明文失败返回 None。"""
if not verify_signature(msg_signature, timestamp, nonce, echostr):
return None
return decrypt(echostr)
def parse_encrypted_body(body: bytes) -> Tuple[str | None, str | None]:
"""解析 POST 请求体 XML取 Encrypt验签用 msg_signature/timestamp/nonce 从 query 传。返回 (encrypt_raw, None) 或 (None, error)。"""
try:
root = ET.fromstring(body)
encrypt_el = root.find("Encrypt")
if encrypt_el is None or encrypt_el.text is None:
return None, "missing Encrypt"
return encrypt_el.text.strip(), None
except Exception as e:
return None, str(e)
def parse_decrypted_xml(plain_xml: str) -> dict | None:
"""解密后的 XML 解析为 dictToUserName, FromUserName, MsgType, Content 等)。"""
try:
root = ET.fromstring(plain_xml)
d = {}
for c in root:
if c.text:
d[c.tag] = c.text
return d
except Exception:
return None
def build_reply_xml(to_user: str, from_user: str, content: str) -> str:
"""构造文本回复 XML明文"""
return f"""<xml>
<ToUserName><![CDATA[{to_user}]]></ToUserName>
<FromUserName><![CDATA[{from_user}]]></FromUserName>
<CreateTime>{int(__import__("time").time())}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{content}]]></Content>
</xml>"""
def make_reply_signature(encrypt: str, timestamp: str, nonce: str) -> str:
lst = [settings.wecom_token, timestamp, nonce, encrypt]
lst.sort()
return _sha1("".join(lst))
def build_encrypted_response(encrypt: str, signature: str, timestamp: str, nonce: str) -> str:
"""构造 POST 回复的加密 XML。"""
return f"""<xml>
<Encrypt><![CDATA[{encrypt}]]></Encrypt>
<MsgSignature><![CDATA[{signature}]]></MsgSignature>
<TimeStamp>{timestamp}</TimeStamp>
<Nonce><![CDATA[{nonce}]]></Nonce>
</xml>"""