ForcePilot/backend/package/yuxi/channels/plugins/wechat_ilink/crypto.py
Kris 8eead29de0 refactor: 批量清理冗余空行,优化部分枚举使用方式
1.  移除所有适配器文件中多余的空导入行
2.  调整ValidationError继承,移除不必要的ValueError继承
3.  修正多处ChannelType使用方式,从.value改为直接使用枚举实例
4.  优化飞书插件部分硬编码渠道类型为枚举实例
5.  更新wechat_ilink插件清单与适配器配置
6.  新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断
7.  优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
2026-07-04 00:14:56 +08:00

188 lines
6.4 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.

"""微信 iLink AES 加解密与 X-WECHAT-UIN 工具模块。
提供 AES-128-ECB 加解密PKCS7 填充)、三种 AES 密钥编码格式归一化、
X-WECHAT-UIN 防重放头生成与上传密文大小计算。
安全说明:
- AES-128-**ECB** 模式为 iLink CDN 协议强制要求(上传密钥由服务端
``getuploadurl`` 返回,下载密钥由消息体提供,客户端无法单方面改 GCM/CBC
ECB 对相同明文块产生相同密文块,存在模式泄露风险;此处通过每次上传
生成一次性随机密钥(``generate_random_aes_key``)缓解,且加密对象为
高熵文件内容(图片/视频),风险有限。**禁止** 将本模块用于通用数据加密。
- ``generate_x_wechat_uin`` 仅 4 字节32-bit为 iLink 协议头格式约定,
不应用于高安全场景的防重放。
仅依赖标准库 + cryptography 库 + contract.errors不 import 框架层。
"""
from __future__ import annotations
import base64
import os
import re
import struct
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
from yuxi.channels.contract.errors import ValidationError
# 32 个十六进制字符的正则,用于识别 Format C 与 Format B 的内层 hex 字符串
_HEX32_PATTERN = re.compile(r"[0-9a-fA-F]{32}")
# AES-128 块大小比特PKCS7 填充参数
_AES_BLOCK_BITS = 128
def normalize_aes_key(aes_key: str) -> bytes:
"""将三种 AES 密钥编码格式归一化为 16 字节原始密钥。
支持的格式(按检测优先级):
- **Format C**: 直接十六进制32 字符),匹配 ``^[0-9a-fA-F]{32}$``,直接 hex 解码。
- **Format A**: base64(原始 16 字节)base64 解码后正好 16 字节。
- **Format B**: base64(十六进制字符串)base64 解码后得到 32 字符 hex 串,再 hex 解码。
Args:
aes_key: AES 密钥字符串,可能为上述任一格式。
Returns:
长度恒为 16 的原始密钥字节。
Raises:
ValidationError: 格式无法识别时抛出 ``invalid_aes_key_format: {aes_key}``
最终长度不为 16 字节时抛出 ``aes_key_must_be_16_bytes``。
"""
# Format C: 32 个十六进制字符,直接 hex 解码
if _HEX32_PATTERN.fullmatch(aes_key):
key = bytes.fromhex(aes_key)
else:
# 尝试 base64 解码validate=True 保证非 base64 字符直接报错
try:
decoded = base64.b64decode(aes_key, validate=True)
except ValueError:
raise ValidationError(
field="aes_key",
message=f"invalid_aes_key_format: {aes_key}",
) from None
if len(decoded) == 16:
# Format A: base64 解码后正好 16 字节
key = decoded
elif len(decoded) == 32:
# Format B: base64 解码后应为 32 字符的十六进制字符串
try:
hex_str = decoded.decode("ascii")
except UnicodeDecodeError:
raise ValidationError(
field="aes_key",
message=f"invalid_aes_key_format: {aes_key}",
) from None
if not _HEX32_PATTERN.fullmatch(hex_str):
raise ValidationError(
field="aes_key",
message=f"invalid_aes_key_format: {aes_key}",
)
key = bytes.fromhex(hex_str)
else:
raise ValidationError(
field="aes_key",
message=f"invalid_aes_key_format: {aes_key}",
)
if len(key) != 16:
raise ValidationError(
field="aes_key",
message="aes_key_must_be_16_bytes",
)
return key
def encrypt(plaintext: bytes, aes_key: str) -> bytes:
"""AES-128-ECB 加密PKCS7 填充)。
Args:
plaintext: 待加密的明文字节。
aes_key: AES 密钥字符串(任一支持格式,由 ``normalize_aes_key`` 归一化)。
Returns:
密文字节。
"""
key = normalize_aes_key(aes_key)
padder = PKCS7(_AES_BLOCK_BITS).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.ECB())
encryptor = cipher.encryptor()
return encryptor.update(padded) + encryptor.finalize()
def decrypt(ciphertext: bytes, aes_key: str) -> bytes:
"""AES-128-ECB 解密PKCS7 去填充)。
Args:
ciphertext: 待解密的密文字节。
aes_key: AES 密钥字符串(任一支持格式,由 ``normalize_aes_key`` 归一化)。
Returns:
明文字节。
"""
key = normalize_aes_key(aes_key)
cipher = Cipher(algorithms.AES(key), modes.ECB())
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = PKCS7(_AES_BLOCK_BITS).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def generate_x_wechat_uin() -> str:
"""生成 X-WECHAT-UIN 防重放头。
流程4 字节随机数 → uint32 大端 → 十进制字符串 → base64 编码。
每次调用产生唯一值,用于请求防重放。
Returns:
base64 编码的 X-WECHAT-UIN 字符串。
"""
raw = os.urandom(4)
uint32_value = struct.unpack(">I", raw)[0]
decimal_str = str(uint32_value)
return base64.b64encode(decimal_str.encode("utf-8")).decode("ascii")
def calc_ciphertext_size(raw_size: int) -> int:
"""计算上传密文大小(含 PKCS7 填充)。
公式:``ceil((raw_size + 1) / 16) * 16``,其中 ``+1`` 保证 ``raw_size``
为 16 整数倍时仍追加一个完整填充块。使用整数算术实现避免浮点误差。
Args:
raw_size: 原始数据字节数。
Returns:
密文字节数16 的整数倍)。
"""
return ((raw_size + 1 + 15) // 16) * 16
def generate_random_aes_key() -> str:
"""生成随机 AES 密钥上传一次性密钥Format A
生成 16 个随机字节,返回其 base64 编码字符串,可被 ``normalize_aes_key``
直接识别为 Format A。
Returns:
base64 编码的 16 字节随机密钥字符串。
"""
raw = os.urandom(16)
return base64.b64encode(raw).decode("ascii")
__all__ = [
"normalize_aes_key",
"encrypt",
"decrypt",
"generate_x_wechat_uin",
"calc_ciphertext_size",
"generate_random_aes_key",
]