Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
119
backend/app/services/wecom_crypto.py
Normal file
119
backend/app/services/wecom_crypto.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""企业微信回调加解密与验签(与企微文档一致)。"""
|
||||
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 解析为 dict(ToUserName, 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>"""
|
||||
Reference in New Issue
Block a user