From bcd8f67c252244464faa3337e242f9293087678d Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sun, 22 Mar 2026 19:52:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(decrypt):=20=E4=BF=AE=E5=A4=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=8E=9F=E5=A7=8B=E5=AF=86=E9=92=A5=E8=A7=A3?= =?UTF-8?q?=E5=AF=86=E4=B8=8E=E8=AE=BE=E5=A4=87=E7=BB=91=E5=AE=9A=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pages/decrypt.vue | 3 + src/wechat_decrypt_tool/routers/decrypt.py | 30 +- src/wechat_decrypt_tool/wechat_decrypt.py | 341 +++++++++++++-------- tests/test_decrypt_stream_sse.py | 100 ++++++ tests/test_wechat_decrypt_raw_key.py | 128 ++++++++ 5 files changed, 468 insertions(+), 134 deletions(-) create mode 100644 tests/test_wechat_decrypt_raw_key.py diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 4e90eed..a13ffa0 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -73,6 +73,9 @@ 点击按钮将自动获取【数据库】与【图片】双重密钥。您也可以手动输入已知的64位密钥(使用wx_key等工具获取)。

+
+ 提示:数据库密钥跟随“账号 + 设备”下发。同一账号在另一台电脑生成的聊天记录,复制到当前设备后,通常无法在当前设备重新获取原设备对应的密钥,因此也无法直接解密。 +
diff --git a/src/wechat_decrypt_tool/routers/decrypt.py b/src/wechat_decrypt_tool/routers/decrypt.py index d797441..433c209 100644 --- a/src/wechat_decrypt_tool/routers/decrypt.py +++ b/src/wechat_decrypt_tool/routers/decrypt.py @@ -14,7 +14,12 @@ from ..app_paths import get_output_databases_dir from ..logging_config import get_logger from ..path_fix import PathFixRoute from ..key_store import upsert_account_keys_in_store -from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path +from ..wechat_decrypt import ( + WeChatDatabaseDecryptor, + build_decrypt_result_message, + decrypt_wechat_databases, + scan_account_databases_from_path, +) logger = get_logger(__name__) @@ -76,6 +81,7 @@ async def decrypt_databases(request: DecryptRequest): "message": results["message"], "processed_files": results["processed_files"], "failed_files": results["failed_files"], + "failure_details": results.get("failure_details", []), "account_results": results.get("account_results", {}), } @@ -159,6 +165,7 @@ async def decrypt_databases_stream( fail_count = 0 processed_files: list[str] = [] failed_files: list[str] = [] + failure_details: list[dict] = [] account_results: dict = {} overall_current = 0 @@ -181,6 +188,7 @@ async def decrypt_databases_stream( account_success = 0 account_processed: list[str] = [] account_failed: list[str] = [] + account_failure_details: list[dict] = [] for db_info in dbs: if await request.is_disconnected(): @@ -232,11 +240,20 @@ async def decrypt_databases_stream( status = "success" msg = "解密成功" else: + failure_detail = { + "account": account, + "file": db_path, + "name": db_name, + "code": str(decryptor.last_error_code or "").strip(), + "reason": str(decryptor.last_error_message or "").strip() or "解密失败", + } account_failed.append(db_path) + account_failure_details.append(failure_detail) failed_files.append(db_path) + failure_details.append(failure_detail) fail_count += 1 status = "fail" - msg = "解密失败" + msg = failure_detail["reason"] yield _sse( { @@ -261,6 +278,7 @@ async def decrypt_databases_stream( "output_dir": str(account_output_dir), "processed_files": account_processed, "failed_files": account_failed, + "failure_details": account_failure_details, } # Build cache table (keep behavior consistent with the POST endpoint). @@ -307,9 +325,15 @@ async def decrypt_databases_stream( "success_count": success_count, "failure_count": total_databases - success_count, "output_directory": str(base_output_dir.absolute()), - "message": f"解密完成: 成功 {success_count}/{total_databases}", + "message": build_decrypt_result_message( + total_databases=total_databases, + success_count=success_count, + failed_count=total_databases - success_count, + failure_details=failure_details, + ), "processed_files": processed_files, "failed_files": failed_files, + "failure_details": failure_details, "account_results": account_results, } diff --git a/src/wechat_decrypt_tool/wechat_decrypt.py b/src/wechat_decrypt_tool/wechat_decrypt.py index 8afcb13..a8fd440 100644 --- a/src/wechat_decrypt_tool/wechat_decrypt.py +++ b/src/wechat_decrypt_tool/wechat_decrypt.py @@ -13,12 +13,12 @@ import hashlib import hmac import os import json +import shutil +import tempfile from pathlib import Path from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from .app_paths import get_output_databases_dir @@ -26,6 +26,94 @@ from .app_paths import get_output_databases_dir # SQLite文件头 SQLITE_HEADER = b"SQLite format 3\x00" +PAGE_SIZE = 4096 +KEY_SIZE = 32 +SALT_SIZE = 16 +IV_SIZE = 16 +HMAC_SIZE = 64 +RESERVE_SIZE = 80 +KEY_MISMATCH_GUIDANCE = ( + "请在当前设备登录该账号后重新获取密钥;" + "如果聊天记录是从另一台设备复制过来的,当前设备通常无法获取原设备对应的密钥。" +) + + +def _derive_mac_key(raw_key: bytes, salt: bytes) -> bytes: + mac_salt = bytes(b ^ 0x3A for b in salt) + return hashlib.pbkdf2_hmac("sha512", raw_key, mac_salt, 2, dklen=KEY_SIZE) + + +def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes: + offset = SALT_SIZE if page_num == 1 else 0 + data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE + mac = hmac.new(mac_key, digestmod=hashlib.sha512) + mac.update(page[offset:data_end]) + mac.update(page_num.to_bytes(4, "little")) + return mac.digest() + + +def _decrypt_page(raw_key: bytes, page: bytes, page_num: int) -> bytes: + iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE] + offset = SALT_SIZE if page_num == 1 else 0 + encrypted = page[offset : PAGE_SIZE - RESERVE_SIZE] + + cipher = Cipher( + algorithms.AES(raw_key), + modes.CBC(iv), + backend=default_backend(), + ) + decryptor = cipher.decryptor() + decrypted = decryptor.update(encrypted) + decryptor.finalize() + + if page_num == 1: + return SQLITE_HEADER + decrypted + (b"\x00" * RESERVE_SIZE) + return decrypted + (b"\x00" * RESERVE_SIZE) + + +def _failure_matches_key_mismatch(detail: dict | None) -> bool: + if not isinstance(detail, dict): + return False + code = str(detail.get("code") or "").strip().lower() + reason = str(detail.get("reason") or "").strip() + if code == "key_mismatch": + return True + return ("密钥" in reason and "不匹配" in reason) or ("当前数据库密钥不正确" in reason) + + +def build_decrypt_result_message( + total_databases: int, + success_count: int, + failed_count: int, + failure_details: list[dict] | None = None, +) -> str: + total = max(int(total_databases or 0), 0) + success = max(int(success_count or 0), 0) + failed = max(int(failed_count or 0), 0) + details = list(failure_details or []) + + if total == 0: + return "未找到可解密的数据库文件" + + if failed == 0: + return f"解密完成: 成功 {success}/{total}" + + key_mismatch_count = sum(1 for item in details if _failure_matches_key_mismatch(item)) + + if success == 0 and failed == total: + if key_mismatch_count == failed: + return ( + f"解密失败:当前数据库密钥不正确,或该密钥不属于当前账号/当前设备(0/{total} 成功)。" + + KEY_MISMATCH_GUIDANCE + ) + return f"解密失败:0/{total} 个数据库解密成功,请检查密钥、账号与数据库路径是否匹配。" + + if key_mismatch_count > 0: + return ( + f"解密完成:成功 {success}/{total},失败 {failed}/{total}。" + "失败文件中包含密钥不匹配的数据库,请确认使用的是当前账号在当前设备上的密钥。" + ) + + return f"解密完成:成功 {success}/{total},失败 {failed}/{total}。" def _normalize_account_name(name: str) -> str: @@ -221,153 +309,123 @@ class WeChatDatabaseDecryptor: self.key_bytes = bytes.fromhex(key_hex) except ValueError: raise ValueError("密钥必须是有效的十六进制字符串") + self.last_error_code = "" + self.last_error_message = "" + + def _set_last_error(self, code: str, message: str) -> None: + self.last_error_code = str(code or "").strip() + self.last_error_message = str(message or "").strip() + + def _clear_last_error(self) -> None: + self.last_error_code = "" + self.last_error_message = "" def decrypt_database(self, db_path: str, output_path: str) -> bool: """解密微信4.x版本数据库 - 使用SQLCipher 4.0参数: - - PBKDF2-SHA512, 256000轮迭代 - - AES-256-CBC加密 - - HMAC-SHA512验证 - - 页面大小4096字节 + 这里传入的 key 已经是从微信进程内存提取出的 raw enc_key, + 不是 SQLCipher 的口令,因此不能再做一轮 PBKDF2。 """ from .logging_config import get_logger logger = get_logger(__name__) logger.info(f"开始解密数据库: {db_path}") - - try: - with open(db_path, 'rb') as f: - encrypted_data = f.read() - - logger.info(f"读取文件大小: {len(encrypted_data)} bytes") - if len(encrypted_data) < 4096: - logger.warning(f"文件太小,跳过解密: {db_path}") + tmp_output_path = "" + self._clear_last_error() + try: + file_size = os.path.getsize(db_path) + logger.info(f"读取文件大小: {file_size} bytes") + + if file_size < PAGE_SIZE: + message = f"数据库文件过小,无法解密: {db_path}" + self._set_last_error("file_too_small", message) + logger.warning(message) + return False + + output_dir = Path(output_path).parent + output_dir.mkdir(parents=True, exist_ok=True) + + with open(db_path, "rb") as source: + page1 = source.read(PAGE_SIZE) + + if len(page1) < PAGE_SIZE: + message = f"数据库首页大小不足,无法解密: {db_path}" + self._set_last_error("page_too_small", message) + logger.warning(message) return False # 检查是否已经是解密的数据库 - if encrypted_data.startswith(SQLITE_HEADER): + if page1.startswith(SQLITE_HEADER): logger.info(f"文件已是SQLite格式,直接复制: {db_path}") - with open(output_path, 'wb') as f: - f.write(encrypted_data) + fd, tmp_output_path = tempfile.mkstemp( + prefix=f".{Path(output_path).name}.", + suffix=".tmp", + dir=str(output_dir), + ) + os.close(fd) + with open(db_path, "rb") as src, open(tmp_output_path, "wb") as dst: + shutil.copyfileobj(src, dst, length=1024 * 1024) + os.replace(tmp_output_path, output_path) + tmp_output_path = "" return True - - # 提取salt (前16字节) - salt = encrypted_data[:16] - - # 计算mac_salt (salt XOR 0x3a) - mac_salt = bytes(b ^ 0x3a for b in salt) - - # 使用PBKDF2-SHA512派生密钥 - kdf = PBKDF2HMAC( - algorithm=hashes.SHA512(), - length=32, - salt=salt, - iterations=256000, - backend=default_backend() - ) - derived_key = kdf.derive(self.key_bytes) - - # 派生MAC密钥 - mac_kdf = PBKDF2HMAC( - algorithm=hashes.SHA512(), - length=32, - salt=mac_salt, - iterations=2, - backend=default_backend() - ) - mac_key = mac_kdf.derive(derived_key) - - # 解密数据 - decrypted_data = bytearray() - decrypted_data.extend(SQLITE_HEADER) - - page_size = 4096 - iv_size = 16 - hmac_size = 64 # SHA512的HMAC是64字节 - - # 计算保留区域大小 (对齐到AES块大小) - reserve_size = iv_size + hmac_size - if reserve_size % 16 != 0: - reserve_size = ((reserve_size // 16) + 1) * 16 - - total_pages = len(encrypted_data) // page_size + + salt = page1[:SALT_SIZE] + mac_key = _derive_mac_key(self.key_bytes, salt) + expected_page1_hmac = _compute_page_hmac(mac_key, page1, 1) + stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE] + if stored_page1_hmac != expected_page1_hmac: + message = f"当前数据库密钥不正确,或该密钥不属于当前账号/当前设备: {db_path}" + self._set_last_error("key_mismatch", message) + logger.error(f"页面 1 HMAC验证失败,密钥与数据库不匹配: {db_path}") + return False + + total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE successful_pages = 0 - failed_pages = 0 - - # 逐页解密 - for cur_page in range(total_pages): - start = cur_page * page_size - end = start + page_size - page = encrypted_data[start:end] - - page_num = cur_page + 1 # 页面编号从1开始 - - if len(page) < page_size: - logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes") - break - - # 确定偏移量:第一页(cur_page == 0)需要跳过salt - offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16 - - # 提取存储的HMAC - hmac_start = page_size - reserve_size + iv_size - hmac_end = hmac_start + hmac_size - stored_hmac = page[hmac_start:hmac_end] - - # 按照wechat-dump-rs的方式验证HMAC - data_end = page_size - reserve_size + iv_size - hmac_data = page[offset:data_end] - - # 分步计算HMAC:先更新数据,再更新页面编号 - mac = hmac.new(mac_key, digestmod=hashlib.sha512) - mac.update(hmac_data) # 包含加密数据+IV - mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序) - expected_hmac = mac.digest() - - if stored_hmac != expected_hmac: - logger.warning(f"页面 {page_num} HMAC验证失败") - failed_pages += 1 - continue - - # 提取IV和加密数据用于AES解密 - iv = page[page_size - reserve_size:page_size - reserve_size + iv_size] - encrypted_page = page[offset:page_size - reserve_size] - - # AES-CBC解密 - try: - cipher = Cipher( - algorithms.AES(derived_key), - modes.CBC(iv), - backend=default_backend() - ) - decryptor = cipher.decryptor() - decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize() - - # 按照wechat-dump-rs的方式重组页面数据 - decrypted_data.extend(decrypted_page) - decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域 - + fd, tmp_output_path = tempfile.mkstemp( + prefix=f".{Path(output_path).name}.", + suffix=".tmp", + dir=str(output_dir), + ) + os.close(fd) + + with open(db_path, "rb") as source, open(tmp_output_path, "wb") as target: + for page_num in range(1, total_pages + 1): + page = source.read(PAGE_SIZE) + if not page: + break + if len(page) < PAGE_SIZE: + logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes,自动补齐到 {PAGE_SIZE} bytes") + page = page + (b"\x00" * (PAGE_SIZE - len(page))) + + stored_hmac = page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE] + expected_hmac = _compute_page_hmac(mac_key, page, page_num) + if stored_hmac != expected_hmac: + message = f"数据库校验失败,文件可能损坏或密钥不匹配: {db_path}" + self._set_last_error("page_hmac_mismatch", message) + logger.error(f"页面 {page_num} HMAC验证失败,终止解密: {db_path}") + return False + + target.write(_decrypt_page(self.key_bytes, page, page_num)) successful_pages += 1 - - except Exception as e: - logger.error(f"页面 {page_num} AES解密失败: {e}") - failed_pages += 1 - continue - logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages} 页") - - # 写入解密后的文件 - with open(output_path, 'wb') as f: - f.write(decrypted_data) - - logger.info(f"解密文件大小: {len(decrypted_data)} bytes") + logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 0 页") + os.replace(tmp_output_path, output_path) + tmp_output_path = "" + logger.info(f"解密文件大小: {os.path.getsize(output_path)} bytes") + self._clear_last_error() return True except Exception as e: + self._set_last_error("exception", f"解密过程中发生异常: {e}") logger.error(f"解密失败: {db_path}, 错误: {e}") return False + finally: + if tmp_output_path: + try: + os.remove(tmp_output_path) + except OSError: + pass def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict: """ @@ -492,6 +550,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di success_count = 0 processed_files = [] failed_files = [] + failure_details = [] account_results = {} for account_name, databases in account_databases.items(): @@ -523,6 +582,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di account_success = 0 account_processed = [] account_failed = [] + account_failure_details = [] for db_info in databases: db_path = db_info['path'] @@ -542,7 +602,16 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di else: account_failed.append(db_path) failed_files.append(db_path) - logger.error(f"解密失败: {account_name}/{db_name}") + failure_detail = { + "account": account_name, + "file": db_path, + "name": db_name, + "code": str(decryptor.last_error_code or "").strip(), + "reason": str(decryptor.last_error_message or "").strip() or "解密失败", + } + account_failure_details.append(failure_detail) + failure_details.append(failure_detail) + logger.error(f"解密失败: {account_name}/{db_name} reason={failure_detail['reason']}") # 记录账号解密结果 account_results[account_name] = { @@ -551,7 +620,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di "failed": len(databases) - account_success, "output_dir": str(account_output_dir), "processed_files": account_processed, - "failed_files": account_failed + "failed_files": account_failed, + "failure_details": account_failure_details, } # 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表 @@ -575,15 +645,23 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di logger.info(f"账号 {account_name} 解密完成: 成功 {account_success}/{len(databases)}") # 返回结果 + failed_count = total_databases - success_count + message = build_decrypt_result_message( + total_databases=total_databases, + success_count=success_count, + failed_count=failed_count, + failure_details=failure_details, + ) result = { "status": "success" if success_count > 0 else "error", - "message": f"解密完成: 成功 {success_count}/{total_databases}", + "message": message, "total_databases": total_databases, "successful_count": success_count, - "failed_count": total_databases - success_count, + "failed_count": failed_count, "output_directory": str(base_output_dir.absolute()), "processed_files": processed_files, "failed_files": failed_files, + "failure_details": failure_details, "account_results": account_results, # 新增:按账号的详细结果 "detected_accounts": detected_accounts, } @@ -591,8 +669,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di logger.info("=" * 60) logger.info("解密任务完成!") logger.info(f"成功: {success_count}/{total_databases}") - logger.info(f"失败: {total_databases - success_count}/{total_databases}") + logger.info(f"失败: {failed_count}/{total_databases}") logger.info(f"输出目录: {base_output_dir.absolute()}") + logger.info(f"结果说明: {message}") logger.info("=" * 60) return result diff --git a/tests/test_decrypt_stream_sse.py b/tests/test_decrypt_stream_sse.py index c041630..f39d04c 100644 --- a/tests/test_decrypt_stream_sse.py +++ b/tests/test_decrypt_stream_sse.py @@ -3,14 +3,44 @@ import os import sys import unittest import importlib +import hashlib +import hmac from pathlib import Path from tempfile import TemporaryDirectory +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) +def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes: + from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SALT_SIZE, _derive_mac_key + + if page_num == 1: + encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE] + prefix = salt + else: + encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE] + prefix = b"" + + cipher = Cipher( + algorithms.AES(raw_key), + modes.CBC(iv), + backend=default_backend(), + ) + encryptor = cipher.encryptor() + encrypted = encryptor.update(encrypted_input) + encryptor.finalize() + + page_without_hmac = prefix + encrypted + iv + mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512) + mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :]) + mac.update(page_num.to_bytes(4, "little")) + return page_without_hmac + mac.digest() + + class TestDecryptStreamSSE(unittest.TestCase): def test_decrypt_stream_reports_progress(self): from fastapi import FastAPI @@ -85,6 +115,76 @@ class TestDecryptStreamSSE(unittest.TestCase): else: os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache + def test_decrypt_stream_reports_key_scope_error_for_wrong_key(self): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SQLITE_HEADER + + good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef") + bad_key = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210" + salt = bytes.fromhex("11223344556677889900aabbccddeeff") + iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + plain_page = SQLITE_HEADER + (b"A" * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))) + (b"\x00" * RESERVE_SIZE) + encrypted_db = _encrypt_page(good_key, plain_page, 1, salt, iv1) + + with TemporaryDirectory() as td: + root = Path(td) + + prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR") + prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE") + try: + os.environ["WECHAT_TOOL_DATA_DIR"] = str(root) + os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0" + + import wechat_decrypt_tool.app_paths as app_paths + import wechat_decrypt_tool.routers.decrypt as decrypt_router + + importlib.reload(app_paths) + importlib.reload(decrypt_router) + + db_storage = root / "xwechat_files" / "wxid_wrong_key_user" / "db_storage" + db_storage.mkdir(parents=True, exist_ok=True) + (db_storage / "MSG0.db").write_bytes(encrypted_db) + + app = FastAPI() + app.include_router(decrypt_router.router) + client = TestClient(app) + + events: list[dict] = [] + with client.stream( + "GET", + "/api/decrypt_stream", + params={"key": bad_key, "db_storage_path": str(db_storage)}, + ) as resp: + self.assertEqual(resp.status_code, 200) + for line in resp.iter_lines(): + if not line: + continue + if isinstance(line, bytes): + line = line.decode("utf-8", errors="ignore") + line = str(line) + if line.startswith(":") or not line.startswith("data: "): + continue + payload = json.loads(line[len("data: ") :]) + events.append(payload) + if payload.get("type") in {"complete", "error"}: + break + + self.assertEqual(events[-1].get("type"), "complete") + self.assertEqual(events[-1].get("status"), "failed") + self.assertIn("当前数据库密钥不正确", events[-1].get("message", "")) + self.assertIn("另一台设备复制", events[-1].get("message", "")) + finally: + if prev_data_dir is None: + os.environ.pop("WECHAT_TOOL_DATA_DIR", None) + else: + os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir + if prev_build_cache is None: + os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None) + else: + os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache + if __name__ == "__main__": unittest.main() diff --git a/tests/test_wechat_decrypt_raw_key.py b/tests/test_wechat_decrypt_raw_key.py new file mode 100644 index 0000000..4588ad2 --- /dev/null +++ b/tests/test_wechat_decrypt_raw_key.py @@ -0,0 +1,128 @@ +import hashlib +import hmac +import os +import sys +import tempfile +import unittest +from pathlib import Path + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool.wechat_decrypt import ( + PAGE_SIZE, + RESERVE_SIZE, + SALT_SIZE, + SQLITE_HEADER, + WeChatDatabaseDecryptor, + _derive_mac_key, + decrypt_wechat_databases, +) + + +def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes: + if page_num == 1: + encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE] + prefix = salt + else: + encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE] + prefix = b"" + + cipher = Cipher( + algorithms.AES(raw_key), + modes.CBC(iv), + backend=default_backend(), + ) + encryptor = cipher.encryptor() + encrypted = encryptor.update(encrypted_input) + encryptor.finalize() + + page_without_hmac = prefix + encrypted + iv + mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512) + mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :]) + mac.update(page_num.to_bytes(4, "little")) + return page_without_hmac + mac.digest() + + +def _build_plain_page(body_byte: int, *, first_page: bool) -> bytes: + if first_page: + payload = SQLITE_HEADER + bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER)) + else: + payload = bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE) + return payload + (b"\x00" * RESERVE_SIZE) + + +class WeChatDecryptRawKeyTests(unittest.TestCase): + def test_decrypt_database_uses_raw_enc_key(self): + raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef") + salt = bytes.fromhex("11223344556677889900aabbccddeeff") + iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20") + + page1 = _build_plain_page(0x41, first_page=True) + page2 = _build_plain_page(0x42, first_page=False) + encrypted_db = _encrypt_page(raw_key, page1, 1, salt, iv1) + _encrypt_page(raw_key, page2, 2, salt, iv2) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "source.db" + dst = Path(tmpdir) / "out.db" + src.write_bytes(encrypted_db) + + decryptor = WeChatDatabaseDecryptor(raw_key.hex()) + self.assertTrue(decryptor.decrypt_database(str(src), str(dst))) + self.assertEqual(dst.read_bytes(), page1 + page2) + + def test_decrypt_database_keeps_existing_output_on_hmac_failure(self): + good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef") + bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210" + salt = bytes.fromhex("11223344556677889900aabbccddeeff") + iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + + page1 = _build_plain_page(0x41, first_page=True) + encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1) + + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "source.db" + dst = Path(tmpdir) / "out.db" + src.write_bytes(encrypted_db) + dst.write_bytes(b"keep-existing-output") + + decryptor = WeChatDatabaseDecryptor(bad_key_hex) + self.assertFalse(decryptor.decrypt_database(str(src), str(dst))) + self.assertEqual(dst.read_bytes(), b"keep-existing-output") + + def test_decrypt_wechat_databases_reports_key_scope_message(self): + good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef") + bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210" + salt = bytes.fromhex("11223344556677889900aabbccddeeff") + iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + + page1 = _build_plain_page(0x41, first_page=True) + encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1) + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + db_storage = root / "xwechat_files" / "wxid_scope_user" / "db_storage" + db_storage.mkdir(parents=True, exist_ok=True) + (db_storage / "MSG0.db").write_bytes(encrypted_db) + + prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR") + try: + os.environ["WECHAT_TOOL_DATA_DIR"] = str(root) + result = decrypt_wechat_databases(str(db_storage), bad_key_hex) + finally: + if prev_data_dir is None: + os.environ.pop("WECHAT_TOOL_DATA_DIR", None) + else: + os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir + + self.assertEqual(result["status"], "error") + self.assertIn("当前数据库密钥不正确", result["message"]) + self.assertIn("账号/当前设备", result["message"]) + self.assertIn("另一台设备复制", result["message"]) + + +if __name__ == "__main__": + unittest.main()