fix: ImageResolver 支持微信 4.0+ V2 加密图片格式 (#61)

ImageResolver.decode_image 之前只调 xor_decrypt_file(老格式 XOR-only
路径),微信 4.0+(2025-08+)已经改用 V2 AES-128-ECB + XOR 混合加密,
导致 mcp_server.py 注册的 decode_image MCP 工具对 V2 .dat 文件返回的
"解密"内容是错的——Claude AI 通过 MCP 调用看不到 V2 时代的图片。

monitor_web.py 早已正确处理 V2(line 41-42, 791-795:从 _cfg 读
image_aes_key / image_xor_key 后调 decrypt_dat_file 自动 magic 分发),
本次把 MCP 路径补齐,行为与 monitor_web.py 对齐。

改动:
- ImageResolver.__init__ 增加 aes_key=None, xor_key=0x88 关键字参数
  (默认值保持向后兼容,老调用方无需改动)
- ImageResolver.decode_image 把 xor_decrypt_file 换成 decrypt_dat_file,
  按 magic 自动分发 V2 / V1 / 老 XOR
- V2 文件 + 缺 aes_key 时早期返回结构化错误信息,避免在 v2_decrypt_file
  内静默失败成笼统的"解密失败"
- v2_decrypt_file 入口接受 xor_key 字符串形式(int(_, 0) 解析),
  与 aes_key 已有的 str→bytes 处理对称,允许 config.json 写 "0x88"
- mcp_server.py 实例化时从 _cfg 读 image_aes_key / image_xor_key 注入

兼容性:
- ImageResolver 老调用方(不传 keys)继续走老 XOR 路径,零 breaking
- V1 magic(\x07\x08V1)不会被 is_v2_format 拦截,走 decrypt_dat_file
  内置固定 key,所以 aes_key=None 也能解 V1 文件
- 整 repo 只有 mcp_server.py 一处生产调用 ImageResolver(...),已 grep 确认

测试覆盖(11 个新测试,tests/test_decode_image_v2.py):
- v2_decrypt_file 合成数据 round-trip 字节级相等
- decrypt_dat_file 按 magic 自动分发 V2 / V1 / 老 XOR 三条路径
- aes_key 接受 str(来自 config.json)和 bytes 两种形式
- xor_key 接受 str(如 "0x88")和 int 两种形式
- V2 wxgf 裸流返回 fmt='hevc'(HEVC→JPEG 转换是 monitor_web 职责,
  不在 ImageResolver 内做,保留 .hevc 输出)
- ImageResolver 端到端:from local_id to decrypted file
- ImageResolver(aes_key=None) + V1 文件走固定 key 路径
- ImageResolver(aes_key=None) + V2 文件返回 success=False + 友好错误
- ImageResolver 默认参数 + 老 XOR .dat 保持向后兼容

测试 46 个全部通过(11 新 + 35 旧)。

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Belugary
2026-05-05 17:05:53 +08:00
committed by GitHub
parent 49356e1692
commit c29e8dd868
3 changed files with 303 additions and 6 deletions

View File

@@ -118,7 +118,7 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
dat_path: V2 .dat 文件路径
out_path: 输出路径 (None 则自动命名)
aes_key: 16 字节 AES key (bytes 或 str)
xor_key: XOR key (int, 默认 0x88)
xor_key: XOR key (int 或可被 int(_, 0) 解析的 str, 默认 0x88)
Returns:
(output_path, format) 或 (None, None)
@@ -135,6 +135,10 @@ def v2_decrypt_file(dat_path, out_path=None, aes_key=None, xor_key=0x88):
if len(aes_key) < 16:
return None, None
# 与 aes_key 的 str→bytes 处理对称: 允许 config.json 写 "0x88" / "136" 等字符串形式
if isinstance(xor_key, str):
xor_key = int(xor_key, 0)
with open(dat_path, 'rb') as f:
data = f.read()
@@ -299,17 +303,21 @@ def extract_md5_from_packed_info(blob):
class ImageResolver:
"""封装从 local_id 到图片文件的完整解析链"""
def __init__(self, wechat_base_dir, decoded_image_dir, cache):
def __init__(self, wechat_base_dir, decoded_image_dir, cache, aes_key=None, xor_key=0x88):
"""
Args:
wechat_base_dir: 微信数据根目录 (如 D:\\xwechat_files\\<wxid>)
decoded_image_dir: 解密图片输出目录
cache: DBCache 实例,用于解密 message_resource.db
aes_key: V2 格式的 AES key (16 字节 str/bytes)None 表示不支持 V2 文件
xor_key: XOR key (int, 默认 0x88),用于 V2 文件的 XOR 段
"""
self.base_dir = wechat_base_dir
self.attach_dir = os.path.join(wechat_base_dir, "msg", "attach")
self.out_dir = decoded_image_dir
self.cache = cache
self.aes_key = aes_key
self.xor_key = xor_key
def get_image_md5(self, local_id):
"""通过 local_id 查 message_resource.db 获取图片文件 MD5"""
@@ -379,13 +387,17 @@ class ImageResolver:
selected = f
break
# 3. 解密
# 3. 解密 (decrypt_dat_file 会按 magic 自动分发 V2 / V1 / 老 XOR)
out_name = f"{file_md5}"
out_path_base = os.path.join(self.out_dir, out_name)
result_path, fmt = xor_decrypt_file(selected, f"{out_path_base}.tmp")
# 提前拦截以给出具体错误信息;否则会在 v2_decrypt_file 内 silent-fail 成笼统的"解密失败"
if is_v2_format(selected) and not self.aes_key:
return {'success': False, 'error': f'V2 格式 .dat 文件需要 AES key (文件: {selected})', 'md5': file_md5}
result_path, fmt = decrypt_dat_file(selected, f"{out_path_base}.tmp", self.aes_key, self.xor_key)
if not result_path:
return {'success': False, 'error': f'无法检测 XOR key (文件: {selected})', 'md5': file_md5}
return {'success': False, 'error': f'解密失败 (文件: {selected})', 'md5': file_md5}
# 重命名为正确扩展名
final_path = f"{out_path_base}.{fmt}"