From 62e95312a3e19fd222f56c8f608c7c2227385db3 Mon Sep 17 00:00:00 2001 From: coderscsy Date: Sun, 16 Aug 2026 15:39:32 +0800 Subject: [PATCH 1/4] feat(macos): add recoverable WCDB key capture draft --- THIRD_PARTY_NOTICES.md | 11 + docs/licenses/wcdb-key-tool-MIT.txt | 21 + docs/macos-wcdb-key-capture-validation.md | 66 + docs/macos-wcdb-key-capture.md | 101 ++ .../macos_clone_capture.py | 1051 +++++++++++++++ .../macos_db_key_capture.py | 1157 +++++++++++++++++ .../macos_db_key_discovery.py | 191 +++ .../macos_inplace_capture.py | 497 +++++++ tests/test_macos_clone_capture.py | 356 +++++ tests/test_macos_db_key_capture.py | 336 +++++ tests/test_macos_db_key_discovery.py | 110 ++ tests/test_macos_inplace_capture.py | 164 +++ tests/test_macos_key_capture_release_audit.py | 47 + tools/audit_macos_key_capture_release.py | 126 ++ 14 files changed, 4234 insertions(+) create mode 100644 docs/licenses/wcdb-key-tool-MIT.txt create mode 100644 docs/macos-wcdb-key-capture-validation.md create mode 100644 docs/macos-wcdb-key-capture.md create mode 100644 src/wechat_decrypt_tool/macos_clone_capture.py create mode 100644 src/wechat_decrypt_tool/macos_db_key_capture.py create mode 100644 src/wechat_decrypt_tool/macos_db_key_discovery.py create mode 100644 src/wechat_decrypt_tool/macos_inplace_capture.py create mode 100644 tests/test_macos_clone_capture.py create mode 100644 tests/test_macos_db_key_capture.py create mode 100644 tests/test_macos_db_key_discovery.py create mode 100644 tests/test_macos_inplace_capture.py create mode 100644 tests/test_macos_key_capture_release_audit.py create mode 100644 tools/audit_macos_key_capture_release.py diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b29f757e..6f1a25da 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,3 +14,14 @@ The optional local voice transcription feature uses the following Python package | `Hugging Face tokenizers` | https://github.com/huggingface/tokenizers | Apache-2.0 | Whisper model weights are not included in this repository or its pull request. Users provide or download model weights separately and must follow the selected model's license and usage terms. + +## Optional macOS WCDB passphrase capture + +The optional Apple Silicon LLDB capture workflow adapts the breakpoint and +register-inspection approach from +[`TANGandXUE/wcdb-key-tool`](https://github.com/TANGandXUE/wcdb-key-tool), +which is distributed under the MIT License. The integration adds target +database validation, transaction recovery, signature verification, and +privacy-preserving diagnostics; it does not bundle user databases or keys. +The complete upstream license text is included at +`docs/licenses/wcdb-key-tool-MIT.txt`. diff --git a/docs/licenses/wcdb-key-tool-MIT.txt b/docs/licenses/wcdb-key-tool-MIT.txt new file mode 100644 index 00000000..741c6f86 --- /dev/null +++ b/docs/licenses/wcdb-key-tool-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CloudDreamAI / TANGandXUE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/macos-wcdb-key-capture-validation.md b/docs/macos-wcdb-key-capture-validation.md new file mode 100644 index 00000000..6a480abd --- /dev/null +++ b/docs/macos-wcdb-key-capture-validation.md @@ -0,0 +1,66 @@ +# macOS WCDB 密钥捕获验证记录(脱敏) + +记录日期:2026-08-16。本文不包含微信账号、昵称、数据库路径、数据库首页、候选密钥、日志原文、个人主目录或证书材料。 + +## Apple Silicon 真实端到端 + +| 项目 | 结果 | +| --- | --- | +| 主机 | Apple Silicon (`arm64`) | +| macOS | 27.0 (build 26A5406e) | +| 微信 | 4.1.12 (build 269341) | +| 安装身份 | 腾讯 Developer ID,Team ID `5A4RE8SF68` | +| 数据源 | 默认沙盒内活动 `app_data/xwechat_files` 数据库 | +| 旧密钥缓存 | 不作为本次捕获成功条件 | +| 捕获 | 重新登录时命中已登记的 arm64 内部返回点;候选长度 32 字节 | +| 验真 | 候选通过所选活动数据库首页 HMAC 校验后才保存 | +| 权限 | 缓存目录 `0700`,密钥文件 `0600` | +| 恢复 | `codesign --verify --deep --strict` 通过;Identifier 与 Tencent Team ID 均匹配 | +| 残留状态 | 成功完成后无 `prepared-in-place-capture.json` | + +真实流程按“准备临时微信 → 用户登录并进入聊天页 → 断点预检并分离 → 用户退出账号 → 启动正式监测 → 用户重新登录同一账号 → 数据库验真 → 恢复官方微信”执行。公开记录只保留布尔结果和版本,不保留密钥或可关联账号的标识。 + +## 异常恢复验证 + +| 场景 | 验证方式 | 期望/结果 | +| --- | --- | --- | +| 用户取消 | 准备完成后调用幂等 cleanup | 临时进程终止,官方签名恢复,状态文件删除 | +| 临时签名失败 | 对签名命令注入异常 | 未完成安全换位时不改动官方微信;已换位时进入统一恢复 | +| 断点预检失败 | 模拟无 PBKDF2/内部返回断点 | 立即停止,不要求用户退出账号,cleanup 恢复 | +| 捕获超时 | 模拟 LLDB 超时且无合格候选 | 不保存候选,`finally` 恢复官方微信 | +| 数据库不匹配 | 使用无法通过目标首页 HMAC 的 32 字节候选 | 返回 `passphrase_database_mismatch`,不写缓存,恢复官方微信 | +| 进程/应用异常退出 | 保留已 fsync 的事务状态后重新进入 | 下次启动优先调用 stale recovery,状态目标和备份路径校验后恢复 | +| 备份路径篡改 | 将状态中的备份改到所选目录之外 | 拒绝覆盖微信并保留可诊断状态 | + +上述故障注入由定向测试覆盖;真实端到端记录覆盖成功路径及最终官方签名恢复。任何自动恢复在无法证明目标、归档或签名身份时都失败关闭,不猜测路径、不覆盖其他应用。 + +## 可复现测试结果 + +在官方 `main` 基线的隔离工作树中运行: + +```text +48 passed in 1.10s +``` + +覆盖范围包括官方备份/版本校验、原子换位、调试 entitlement、LLDB 输出解析、PBKDF2 参数和 salt 过滤、内部返回点预检、候选数据库验真、状态文件权限、取消/超时/异常恢复、活动库优先和发布审计。 + +完整测试也在同一 Python 环境中对官方基线和本 Draft 分别执行。官方 `f6583c5` 基线为 `828 passed, 110 failed, 9 errors, 2 skipped`;本 Draft 为 `876 passed, 110 failed, 9 errors, 2 skipped`。新增 48 项全部通过,失败/错误数量与基线完全一致。现有失败集中于本机未提供仓库受限 native core、导出完整性运行时和语音模型环境,因此不把它们误报为本 PR 引入的回归。 + +## 发布包审计结果 + +审计对象为本地 Apple Silicon 独立验证包(仅用于验证,不随 PR 提交二进制): + +```text +WeDataKeyExtractor-1.1.8-mac-arm64.zip +SHA-256: 5d6f4e1a4dffac147fe2dff717674fa5061c7e0179e30c083621cb831d3873cb +发布审计通过:未发现个人路径、数据库、日志或密钥缓存文件。 +``` + +同一审计同时扫描了解压目录和 ZIP 内每个条目的内容。未发现数据库、日志、偏好、密钥缓存、个人绝对路径或当前构建用户名;源码提交中也不包含本地签名证书或私钥。PR 本身不上传该验证包,避免把未经上游签名、公证的二进制作为正式产物传播。 + +## 限制和后续评审点 + +- 当前真实记录不能推断 Intel Mac 或未来微信版本已兼容; +- 当前 Draft 故意不替换现有 helper,也不改变默认 UI/API; +- 维护者确认状态机后,应在后续小 PR 中决定调用模式、前端确认步骤和正式 Release 的 Developer ID/公证流程; +- 发布前必须在上游正式构建产物上再次执行同一审计,不能用本地验证包结果替代正式 Release 审计。 diff --git a/docs/macos-wcdb-key-capture.md b/docs/macos-wcdb-key-capture.md new file mode 100644 index 00000000..6fe5af93 --- /dev/null +++ b/docs/macos-wcdb-key-capture.md @@ -0,0 +1,101 @@ +# macOS Apple Silicon WCDB 密钥捕获(Draft) + +> 仅用于处理当前用户本人拥有或已获明确授权的本机微信数据。本流程需要用户亲自在手机端确认登录;不会绕过登录、联网传输密钥或长期注入微信进程。 + +## 本 Draft 的边界 + +本次提交只增加可审查的 Python 后端状态机、数据库验真、恢复逻辑、测试和发布审计脚本。它暂不替换现有的 macOS 受限原生 helper,也不把 LLDB 路径接成默认 UI 流程。维护者确认接口和恢复边界后,再单独提交 API/UI 接线,避免一次 PR 同时改变两套获取方式。 + +核心文件: + +- `macos_db_key_capture.py`:腾讯签名识别、备份、临时签名、LLDB 调用、密钥格式及数据库验真; +- `macos_clone_capture.py`:APFS 写时复制隔离方案、断点预检、salt 匹配和兼容 UUID 表; +- `macos_inplace_capture.py`:可恢复的原路径临时重签事务和重启恢复; +- `macos_db_key_discovery.py`:只接受能通过所选数据库首页 HMAC 校验的本地候选。 + +## 安全状态机 + +1. 规范化并锁定明确的 `/Applications/WeChat.app`,验证 bundle identifier、腾讯 Team ID、深度签名和版本。 +2. 读取所选账号的加密数据库首页;拒绝明文 SQLite、短文件和不可读文件。 +3. 在任何改动前创建并验证官方微信恢复归档,再将本机恢复状态以 `0600` 原子写入磁盘。 +4. 只对一次捕获所需的临时实例进行调试签名。先登录进入聊天页,再短暂附加做断点预检并立即分离。 +5. 用户在未监测状态退出账号;正式监测开始后重新登录同一账号。 +6. 通用路径只接受符合 WCDB 参数形状且 salt 命中的 PBKDF2 调用;已知版本还可使用按模块 UUID 明确登记的内部返回点。未知 UUID 不猜偏移。 +7. 32 字节候选必须通过目标数据库首页 HMAC 校验才会保存,缓存目录和文件分别使用 `0700`、`0600`。 +8. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。 + +## 源码复现 + +前提:Apple Silicon Mac、Xcode Command Line Tools、腾讯官方签名微信,以及运行 Python/桌面应用的完全磁盘访问权限。下面的路径均为示例,不能直接复制真实密钥、账号名或聊天数据库到 Issue/PR。 + +先运行不接触真实微信的回归测试: + +```bash +python -m pytest -q \ + tests/test_macos_db_key_capture.py \ + tests/test_macos_clone_capture.py \ + tests/test_macos_inplace_capture.py \ + tests/test_macos_db_key_discovery.py \ + tests/test_macos_key_capture_release_audit.py +``` + +真实端到端验证采用显式的三阶段调用,便于 UI 在每一步向用户确认: + +```python +from pathlib import Path + +from wechat_decrypt_tool.macos_db_key_capture import ( + capture_prepared_macos_passphrase, + cleanup_macos_passphrase_capture, + prepare_macos_passphrase_capture, + preflight_prepared_macos_passphrase, +) + +wechat = Path("/Applications/WeChat.app") +backup_root = Path("/path/to/private/recovery-directory") +probe_db = Path("/path/to/active/app_data/xwechat_files/account/db_storage/message/message_0.db") + +try: + prepare_macos_passphrase_capture(wechat, backup_root=backup_root) + # 用户登录临时微信并进入聊天页后: + preflight_prepared_macos_passphrase(wechat, backup_root=backup_root) + # 用户先退出账号;监测开始后再重新登录同一账号: + result = capture_prepared_macos_passphrase( + wechat, + backup_root=backup_root, + probe_db_path=probe_db, + ) + assert result["official_wechat_verified"] is True +finally: + # 可以重复调用;显式取消和异常退出也使用此恢复入口。 + cleanup_macos_passphrase_capture(wechat, backup_root=backup_root) +``` + +结束后必须验证官方微信: + +```bash +codesign --verify --deep --strict --verbose=2 /Applications/WeChat.app +codesign -dv --verbose=4 /Applications/WeChat.app 2>&1 \ + | grep -E '^(Identifier|TeamIdentifier|Authority)=' +``` + +期望结果包含 `Identifier=com.tencent.xinWeChat`、`TeamIdentifier=5A4RE8SF68`,且深度签名验证成功。 + +## 发布审计 + +对解压后的 `.app` 和最终 `.zip` 都运行: + +```bash +python tools/audit_macos_key_capture_release.py \ + /path/to/WeChatDataAnalysis.app \ + /path/to/WeChatDataAnalysis-mac-arm64.zip +``` + +审计会拒绝数据库、日志、偏好、密钥缓存、`.env`、构建者主目录绝对路径及当前构建用户名。证书与私钥不属于源码或运行时资源,也不得加入安装包。 + +## 当前兼容性 + +- 已验证:Apple Silicon、微信 4.1.12(build 269341)、活动 `Documents/app_data/xwechat_files` 数据库; +- 未验证:Intel Mac、其他微信版本、多套并行安装; +- 已知内部返回点仅按 Mach-O UUID 精确启用;版本未知时只保留系统 PBKDF2 路径并返回明确诊断; +- 断点预检不通过时流程必须停止,不能要求用户继续退出账号。 diff --git a/src/wechat_decrypt_tool/macos_clone_capture.py b/src/wechat_decrypt_tool/macos_clone_capture.py new file mode 100644 index 00000000..1140ec5d --- /dev/null +++ b/src/wechat_decrypt_tool/macos_clone_capture.py @@ -0,0 +1,1051 @@ +"""Safe macOS WCDB passphrase capture using an isolated APFS clone. + +The Tencent-signed application is never modified. A disposable ad-hoc copy +runs with a private HOME containing a copy-on-write clone of the real WeChat +container. LLDB is attached only to that disposable process and accepts a +PBKDF2 password only when both the SQLCipher iteration count and database salt +match databases in the cloned container. +""" + +from __future__ import annotations + +import ctypes +import errno +import json +import os +import platform +import plistlib +import re +import shutil +import tempfile +import time +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from .macos_db_key_capture import ( + DEFAULT_DEBUG_ROOT, + MacOSDBKeyCaptureFailure, + _build_lldb_capture_command, + _debug_copy_path, + _find_wechat_main_pid, + _has_compatible_debug_entitlements, + _has_debug_copy_marker, + _is_tencent_official_signature, + _launch_wechat, + _mark_debug_copy, + _quit_wechat, + _run, + _run_as_administrator, + _validate_captured_passphrase, + inspect_wechat_signature, + normalize_wechat_app_path, + save_passphrase, +) + +PREPARED_STATE_NAME = "prepared-clone-capture.json" +BREAKPOINT_PREFLIGHT_NAME = "breakpoint-preflight.json" +WECHAT_CONTAINER_RELATIVE = Path("Library/Containers/com.tencent.xinWeChat") +WECHAT_DOCUMENTS_RELATIVE = Path("Documents") +# Verified against the installed Tencent WeChat 4.1.12 arm64 image. The +# breakpoint is the instruction immediately after the function that returns +# the 32-byte WCDB passphrase in a libc++ string at x29-0xb8. +WECHAT_KEY_RETURN_POINTS = { + "1B1A6433-A445-3247-B7E1-753C09CDB137": 0x2FC6880, +} + + +def _state_path(debug_root: Path) -> Path: + return debug_root.expanduser() / PREPARED_STATE_NAME + + +def _breakpoint_preflight_path(debug_root: Path) -> Path: + return debug_root.expanduser() / BREAKPOINT_PREFLIGHT_NAME + + +def _write_state(debug_root: Path, payload: dict[str, Any]) -> Path: + target = _state_path(debug_root) + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + os.chmod(temporary, 0o600) + temporary.replace(target) + os.chmod(target, 0o600) + return target + + +def _read_state(debug_root: Path) -> dict[str, Any]: + target = _state_path(debug_root) + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as exc: + raise MacOSDBKeyCaptureFailure( + "clone_capture_not_prepared", + "没有找到已准备好的隔离微信。请重新执行第一步。", + ) from exc + if not isinstance(payload, dict): + raise MacOSDBKeyCaptureFailure("clone_capture_state_invalid", "隔离微信状态文件无效,请重新准备") + return payload + + +def _remove_state(debug_root: Path) -> None: + target = _state_path(debug_root) + if target.is_file(): + target.unlink() + + +def _remove_breakpoint_preflight(debug_root: Path) -> None: + target = _breakpoint_preflight_path(debug_root) + try: + target.unlink() + except FileNotFoundError: + pass + + +def _is_safe_clone_profile(profile: Path, debug_root: Path) -> bool: + try: + resolved = profile.expanduser().resolve(strict=False) + root = debug_root.expanduser().resolve(strict=False) + except OSError: + return False + return resolved.parent == root and resolved.name.startswith("profile-clone-") + + +def _remove_clone_profile(profile: Path, debug_root: Path) -> None: + if not _is_safe_clone_profile(profile, debug_root): + raise MacOSDBKeyCaptureFailure( + "clone_profile_path_unsafe", + f"拒绝清理非 WCDA 隔离目录: {profile}", + ) + if profile.exists(): + last_error: OSError | None = None + for _attempt in range(5): + try: + shutil.rmtree(profile) + return + except OSError as exc: + last_error = exc + time.sleep(0.4) + if profile.exists() and last_error is not None: + raise last_error + + +def _debug_copy_is_ready(debug_app: Path) -> bool: + if not debug_app.is_dir(): + return False + signature = inspect_wechat_signature(debug_app) + return bool( + signature.get("valid") + and signature.get("ad_hoc") + and not signature.get("hardened_runtime") + and _has_compatible_debug_entitlements(debug_app) + and _has_debug_copy_marker(debug_app) + ) + + +def _ensure_disposable_debug_copy(wechat_app: Path, debug_root: Path) -> tuple[Path, bool]: + """Create a copy-on-write app copy; never sign the installed application.""" + + debug_root = debug_root.expanduser() + debug_root.mkdir(parents=True, exist_ok=True) + os.chmod(debug_root, 0o700) + debug_app = _debug_copy_path(wechat_app, debug_root) + if _debug_copy_is_ready(debug_app): + return debug_app, False + if debug_app.exists(): + if debug_app.parent.resolve() != debug_root.resolve(): + raise MacOSDBKeyCaptureFailure("debug_copy_path_unsafe", f"调试副本路径不安全: {debug_app}") + shutil.rmtree(debug_app) + + with tempfile.TemporaryDirectory(prefix="app-copy-", dir=str(debug_root)) as temporary_dir: + staged_app = Path(temporary_dir) / "WeChat.app" + # clonefile(2) keeps the temporary copy cheap while preserving bundle + # metadata. Failure is explicit; there is no physical-copy fallback. + _clone_path_force(wechat_app, staged_app) + shutil.move(str(staged_app), str(debug_app)) + + _mark_debug_copy(debug_app) + _run(["/usr/bin/xattr", "-dr", "com.apple.quarantine", str(debug_app)], timeout=300, check=False) + _run(["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(debug_app)], timeout=300) + if not _debug_copy_is_ready(debug_app): + raise MacOSDBKeyCaptureFailure("debug_copy_sign_failed", "独立微信调试副本签名校验失败") + return debug_app, True + + +def _filesystem_type(path: Path) -> str: + """Return the mounted filesystem type for a local clone source.""" + + lines = _run(["/bin/df", "-P", str(path)], timeout=30).stdout.splitlines() + if len(lines) < 2: + raise MacOSDBKeyCaptureFailure("clone_filesystem_unknown", f"无法识别快照文件系统: {path}") + device = lines[-1].split()[0] + info = _run(["/usr/sbin/diskutil", "info", "-plist", device], timeout=30).stdout + try: + payload = plistlib.loads(info.encode("utf-8")) + except (plistlib.InvalidFileException, ValueError) as exc: + raise MacOSDBKeyCaptureFailure("clone_filesystem_unknown", f"无法读取快照文件系统信息: {path}") from exc + return str(payload.get("FilesystemType") or "").strip().lower() + + +def _is_wechat_container_path(path: Path) -> bool: + """Classify access errors without resolving protected container paths.""" + + try: + candidate = Path(os.path.abspath(os.path.normpath(os.fspath(path.expanduser())))) + container_root = Path.home() / WECHAT_CONTAINER_RELATIVE + return candidate == container_root or container_root in candidate.parents + except (OSError, TypeError, ValueError): + return False + + +def _raise_clone_source_error(source: Path, error_number: int, *, cause: OSError | None = None) -> None: + if _is_wechat_container_path(source) and error_number in {errno.EACCES, errno.EPERM}: + failure = MacOSDBKeyCaptureFailure( + "database_permission_denied", + "macOS 已阻止 WeChatDataAnalysis 读取微信默认数据容器。请在“系统设置 → 隐私与安全性 → 完全磁盘访问权限”中启用 /Applications/WeChatDataAnalysis.app,然后完全退出并重启本应用后重试。", + ) + if cause is not None: + raise failure from cause + raise failure + + +def _require_local_apfs_clone(source: Path, destination_parent: Path) -> None: + """Fail closed before ``cp -c`` could become a physical/network copy.""" + + try: + source_stat = source.stat() + except OSError as exc: + _raise_clone_source_error(source, int(exc.errno or 0), cause=exc) + raise MacOSDBKeyCaptureFailure("clone_source_unavailable", f"无法访问写时复制来源: {source}") from exc + try: + destination_stat = destination_parent.stat() + except OSError as exc: + raise MacOSDBKeyCaptureFailure( + "clone_destination_unavailable", + f"无法访问写时复制目标目录: {destination_parent}", + ) from exc + if source.is_symlink(): + raise MacOSDBKeyCaptureFailure("clone_source_symlink", f"拒绝从符号链接创建微信数据快照: {source}") + if source_stat.st_dev != destination_stat.st_dev: + raise MacOSDBKeyCaptureFailure( + "clone_cross_device_blocked", + "微信数据不在本机快照卷;为避免把网络盘或外部卷数据整库复制到系统盘,已停止操作。", + ) + if _filesystem_type(source) != "apfs" or _filesystem_type(destination_parent) != "apfs": + raise MacOSDBKeyCaptureFailure( + "clone_requires_apfs", + "隔离登录只允许使用本机 APFS 写时复制快照,已拒绝普通整库复制。", + ) + + +def _clone_path_force(source: Path, destination: Path) -> None: + """Clone a file hierarchy through clonefile(2), which never copies data.""" + + _require_local_apfs_clone(source, destination.parent) + if os.path.lexists(destination): + raise MacOSDBKeyCaptureFailure("clone_destination_exists", f"写时复制目标已存在: {destination}") + libc = ctypes.CDLL(None, use_errno=True) + clonefile = libc.clonefile + clonefile.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] + clonefile.restype = ctypes.c_int + if clonefile(os.fsencode(source), os.fsencode(destination), 0) != 0: + error_number = ctypes.get_errno() + _raise_clone_source_error(source, error_number) + raise MacOSDBKeyCaptureFailure( + "clonefile_failed", + f"无法创建 APFS 写时复制快照: {source.name} ({os.strerror(error_number)})", + ) + + +def _clone_real_wechat_container(debug_root: Path) -> Path: + source = Path.home() / WECHAT_CONTAINER_RELATIVE + if not source.is_dir(): + raise MacOSDBKeyCaptureFailure("wechat_container_missing", f"找不到微信默认数据容器: {source}") + + profile = Path(tempfile.mkdtemp(prefix="profile-clone-", dir=str(debug_root))) + os.chmod(profile, 0o700) + destination = profile / WECHAT_CONTAINER_RELATIVE + destination.parent.mkdir(parents=True, exist_ok=True) + try: + _clone_path_force(source, destination) + if not destination.is_dir(): + raise MacOSDBKeyCaptureFailure("clone_profile_failed", "微信容器写时复制快照未创建") + source_data = source / "Data" + source_documents = source_data / "Documents" + # CFFIXED_USER_HOME points at ``profile``. Mirror the complete sandbox + # Data home there (Documents/Library/tmp), while retaining the nested + # container layout used by WeChatAppEx's explicit command-line paths. + for child in source_data.iterdir(): + if child.is_symlink(): + continue + if child.name == "Library": + # The nested container clone above already created + # profile/Library/Containers. Merge the remaining Foundation + # home entries one at a time instead of replacing that tree. + private_library = profile / "Library" + private_library.mkdir(parents=True, exist_ok=True) + for library_child in child.iterdir(): + if library_child.is_symlink(): + continue + _clone_path_force(library_child, private_library / library_child.name) + continue + _clone_path_force(child, profile / child.name) + private_documents = profile / WECHAT_DOCUMENTS_RELATIVE + # The user's configured xwechat_files may be a symlink to a network or external volume. + # Never let the disposable client follow that link: materialize an + # independent copy inside both private layouts instead. + _materialize_private_xwechat_files(source_documents, destination / "Data/Documents") + _materialize_private_xwechat_files(source_documents, private_documents) + except Exception: + _remove_clone_profile(profile, debug_root) + raise + return profile + + +def _materialize_private_xwechat_files(source_documents: Path, cloned_documents: Path) -> None: + destination = cloned_documents / "xwechat_files" + local_candidate = source_documents / "app_data/xwechat_files" + source: Path | None = ( + local_candidate + if local_candidate.is_dir() and not local_candidate.is_symlink() + else None + ) + if source is None: + local_backups = sorted( + ( + candidate + for candidate in source_documents.glob("xwechat_files.local-backup-*") + if candidate.is_dir() and not candidate.is_symlink() + ), + key=lambda candidate: candidate.stat().st_mtime, + reverse=True, + ) + source = local_backups[0] if local_backups else None + if source is None or not source.is_dir(): + raise MacOSDBKeyCaptureFailure( + "wechat_data_snapshot_source_missing", + "默认容器中没有本机 xwechat_files 数据副本;为避免从网络盘或外部卷整库复制到系统盘,已停止操作。", + ) + + if destination.is_symlink() or destination.is_file(): + destination.unlink() + elif destination.is_dir(): + shutil.rmtree(destination) + _clone_path_force(source, destination) + + +def _collect_database_salts(profile: Path) -> list[str]: + container = profile / WECHAT_CONTAINER_RELATIVE + salts: set[str] = set() + for database in container.rglob("*.db"): + if "db_storage" not in database.parts: + continue + try: + with database.open("rb") as handle: + page = handle.read(4096) + except OSError: + continue + if len(page) < 4096 or page.startswith(b"SQLite format 3"): + continue + salts.add(page[:16].hex()) + if not salts: + raise MacOSDBKeyCaptureFailure( + "clone_database_salts_missing", + "隔离容器中没有找到可用于匹配密钥的加密微信数据库", + ) + return sorted(salts) + + +def _normalize_salts(values: Iterable[str | bytes]) -> list[str]: + normalized: set[str] = set() + for value in values: + candidate = value.hex() if isinstance(value, bytes) else str(value or "").strip().lower() + if len(candidate) == 32 and all(char in "0123456789abcdef" for char in candidate): + normalized.add(candidate) + return sorted(normalized) + + +def build_lldb_salt_capture_script( + result_path: Path, + expected_salts: Iterable[str | bytes], + *, + probe_page1: bytes | None = None, + enable_key_return_fallback: bool = True, +) -> str: + """Build callbacks that validate candidates before stopping the process.""" + + salts = _normalize_salts(expected_salts) + if not salts: + raise MacOSDBKeyCaptureFailure("capture_salts_missing", "没有可用于过滤 PBKDF2 调用的数据库 salt") + result_literal = json.dumps(str(result_path)) + salts_literal = json.dumps(salts) + page1_literal = json.dumps(bytes(probe_page1 or b"").hex()) + return f'''import hashlib +import hmac +import json +import lldb +import os + +RESULT_PATH = {result_literal} +EXPECTED_SALTS = frozenset({salts_literal}) +PROBE_PAGE1 = bytes.fromhex({page1_literal}) +KEY_RETURN_POINTS = {json.dumps(WECHAT_KEY_RETURN_POINTS)} +ENABLE_KEY_RETURN_FALLBACK = {bool(enable_key_return_fallback)!r} +MODULE_NAME = __name__ +DIAGNOSTICS = {{ + "pbkdf_calls": 0, + "pbkdf_shape_hits": 0, + "pbkdf_salt_hits": 0, + "key_return_hits": 0, + "candidate_rejections": 0, +}} + +def _write_result(payload): + flags = os.O_WRONLY | os.O_TRUNC + flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(RESULT_PATH, flags) + try: + data = json.dumps(payload).encode() + view = memoryview(data) + while view: + written = os.write(descriptor, view) + if written <= 0: + return False + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + return True + +def _record_diagnostic(name): + DIAGNOSTICS[name] = int(DIAGNOSTICS.get(name, 0)) + 1 + _write_result({{"diagnostics": dict(DIAGNOSTICS)}}) + +def _register(frame, name): + return frame.FindRegister(name).GetValueAsUnsigned() + +def _candidate_matches_page1(candidate): + if len(candidate) != 32 or len(PROBE_PAGE1) < 4096: + return False + salt = PROBE_PAGE1[:16] + stored_hmac = PROBE_PAGE1[4032:4096] + for enc_key in (candidate, hashlib.pbkdf2_hmac("sha512", candidate, salt, 256000, 32)): + mac_salt = bytes(value ^ 0x3A for value in salt) + mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, 32) + digest = hmac.new(mac_key, digestmod=hashlib.sha512) + digest.update(PROBE_PAGE1[16:4032]) + digest.update((1).to_bytes(4, "little")) + if hmac.compare_digest(stored_hmac, digest.digest()): + return True + return False + +def _normalize_candidate(raw): + if len(raw) == 32: + return raw + if len(raw) == 64: + try: + decoded = bytes.fromhex(raw.decode("ascii")) + except (UnicodeDecodeError, ValueError): + return b"" + return decoded if len(decoded) == 32 else b"" + return b"" + +def _save_valid_candidate(candidate, salt, source, process): + normalized = _normalize_candidate(candidate) + if not _candidate_matches_page1(normalized): + _record_diagnostic("candidate_rejections") + return False + _write_result({{ + "passphrase": normalized.hex(), + "salt": salt, + "source": source, + "diagnostics": dict(DIAGNOSTICS), + }}) + print("WEDATA_MATCHED_VALIDATED_DATABASE_KEY", source, flush=True) + process.Kill() + os._exit(0) + +def _pbkdf_callback(frame, bp_loc, _internal_dict): + process = frame.GetThread().GetProcess() + _record_diagnostic("pbkdf_calls") + algorithm = _register(frame, "x0") + password_ptr = _register(frame, "x1") + password_len = _register(frame, "x2") + salt_ptr = _register(frame, "x3") + salt_len = _register(frame, "x4") + prf = _register(frame, "x5") + rounds = _register(frame, "x6") + if algorithm != 2 or password_len != 32 or salt_len != 16 or prf != 5 or rounds != 256000: + return False + _record_diagnostic("pbkdf_shape_hits") + + error = lldb.SBError() + salt = process.ReadMemory(salt_ptr, salt_len, error) + if not error.Success() or len(salt) != 16 or salt.hex() not in EXPECTED_SALTS: + return False + _record_diagnostic("pbkdf_salt_hits") + password = process.ReadMemory(password_ptr, password_len, error) + if not error.Success() or len(password) != 32: + return False + + _save_valid_candidate(password, salt.hex(), "pbkdf2", process) + return False + +def _read_libcxx_string(frame): + process = frame.GetThread().GetProcess() + object_address = _register(frame, "x29") - 0xB8 + error = lldb.SBError() + header = process.ReadMemory(object_address, 24, error) + if not error.Success() or len(header) != 24: + return b"" + flag = header[23] + if flag & 0x80: + data_address = int.from_bytes(header[0:8], "little") + length = int.from_bytes(header[8:16], "little") + else: + data_address = object_address + length = flag + if length <= 0 or length > 128: + return b"" + value = process.ReadMemory(data_address, length, error) + return value if error.Success() and len(value) == length else b"" + +def _key_return_callback(frame, bp_loc, _internal_dict): + process = frame.GetThread().GetProcess() + _record_diagnostic("key_return_hits") + candidate = _read_libcxx_string(frame) + _save_valid_candidate(candidate, PROBE_PAGE1[:16].hex(), "wechat_key_return", process) + return False + +def _setup(debugger, _command, _result, _internal_dict): + target = debugger.GetSelectedTarget() + breakpoint = target.BreakpointCreateByName("CCKeyDerivationPBKDF") + breakpoint.SetScriptCallbackFunction(f"{{MODULE_NAME}}._pbkdf_callback") + breakpoint.SetAutoContinue(True) + pbkdf_locations = breakpoint.GetNumResolvedLocations() + key_locations = 0 + if ENABLE_KEY_RETURN_FALLBACK: + for module in target.module_iter(): + uuid = (module.GetUUIDString() or "").upper() + offset = KEY_RETURN_POINTS.get(uuid) + if offset is None: + continue + address = module.ResolveFileAddress(offset) + section = address.GetSection() if address.IsValid() else lldb.SBSection() + if ( + not address.IsValid() + or not section.IsValid() + or not (section.GetPermissions() & lldb.ePermissionsExecutable) + or address.GetLoadAddress(target) == lldb.LLDB_INVALID_ADDRESS + ): + continue + key_breakpoint = target.BreakpointCreateBySBAddress(address) + key_breakpoint.SetScriptCallbackFunction(f"{{MODULE_NAME}}._key_return_callback") + key_breakpoint.SetAutoContinue(True) + key_locations += key_breakpoint.GetNumResolvedLocations() + print("WEDATA_KEY_MONITOR_READY", pbkdf_locations, key_locations, flush=True) + if pbkdf_locations <= 0 and key_locations <= 0: + process = target.GetProcess() + process.Detach() + os._exit(24) + +def __lldb_init_module(debugger, _internal_dict): + debugger.HandleCommand(f"command script add -f {{MODULE_NAME}}._setup wedata_capture") +''' + + +def build_lldb_breakpoint_preflight_script(result_path: Path) -> str: + """Build a read-only LLDB command that verifies usable runtime breakpoints.""" + + result_literal = json.dumps(str(result_path)) + return f'''import json +import lldb +import os + +RESULT_PATH = {result_literal} +KEY_RETURN_POINTS = {json.dumps(WECHAT_KEY_RETURN_POINTS)} +MODULE_NAME = __name__ + +def _write_result(payload): + flags = os.O_WRONLY | os.O_TRUNC + flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(RESULT_PATH, flags) + try: + data = json.dumps(payload).encode("utf-8") + view = memoryview(data) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + +def _resolved_locations(breakpoint): + try: + return breakpoint.GetNumResolvedLocations() + except AttributeError: + return sum(1 for index in range(breakpoint.GetNumLocations()) if breakpoint.GetLocationAtIndex(index).IsResolved()) + +def _setup(debugger, _command, _result, _internal_dict): + target = debugger.GetSelectedTarget() + process = target.GetProcess() + pbkdf_breakpoint = target.BreakpointCreateByName("CCKeyDerivationPBKDF") + pbkdf_locations = _resolved_locations(pbkdf_breakpoint) + pbkdf_breakpoint.SetEnabled(False) + key_locations = 0 + matched_modules = [] + rejected_points = [] + for module in target.module_iter(): + uuid = (module.GetUUIDString() or "").upper() + offset = KEY_RETURN_POINTS.get(uuid) + if offset is None: + continue + module_name = module.GetFileSpec().GetFilename() or "unknown" + address = module.ResolveFileAddress(offset) + section = address.GetSection() if address.IsValid() else lldb.SBSection() + load_address = address.GetLoadAddress(target) if address.IsValid() else lldb.LLDB_INVALID_ADDRESS + executable = bool(section.IsValid() and section.GetPermissions() & lldb.ePermissionsExecutable) + if ( + not address.IsValid() + or not section.IsValid() + or not executable + or load_address == lldb.LLDB_INVALID_ADDRESS + ): + rejected_points.append({{ + "module": module_name, + "uuid": uuid, + "offset": offset, + "section": section.GetName() if section.IsValid() else "", + }}) + continue + breakpoint = target.BreakpointCreateBySBAddress(address) + locations = _resolved_locations(breakpoint) + breakpoint.SetEnabled(False) + if locations > 0: + key_locations += locations + matched_modules.append({{ + "module": module_name, + "uuid": uuid, + "offset": offset, + "section": section.GetName() or "", + }}) + payload = {{ + "pid": process.GetProcessID(), + "pbkdf_locations": pbkdf_locations, + "key_return_locations": key_locations, + "matched_modules": matched_modules, + "rejected_points": rejected_points, + }} + _write_result(payload) + print("WEDATA_BREAKPOINT_PREFLIGHT", pbkdf_locations, key_locations, flush=True) + process.Detach() + +def __lldb_init_module(debugger, _internal_dict): + debugger.HandleCommand(f"command script add -f {{MODULE_NAME}}._setup wedata_preflight") +''' + + +def preflight_capture_breakpoints(*, pid: int, debug_root: Path = DEFAULT_DEBUG_ROOT) -> dict[str, Any]: + """Attach briefly, validate breakpoint locations, and detach before re-login.""" + + if platform.machine().lower() not in {"arm64", "aarch64"}: + raise MacOSDBKeyCaptureFailure("capture_arch_unsupported", "当前安全捕获流程仅支持 Apple Silicon Mac") + if shutil.which("lldb") is None: + raise MacOSDBKeyCaptureFailure("lldb_missing", "未安装 LLDB,请先运行 xcode-select --install") + + result_path = _breakpoint_preflight_path(debug_root) + result_path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(result_path.parent, 0o700) + _remove_breakpoint_preflight(debug_root) + result_path.touch(mode=0o600, exist_ok=False) + os.chmod(result_path, 0o600) + with tempfile.TemporaryDirectory(prefix="wedata-breakpoint-preflight-") as temporary_dir: + root = Path(temporary_dir) + callback_path = root / "preflight_callback.py" + callback_path.write_text(build_lldb_breakpoint_preflight_script(result_path), encoding="utf-8") + os.chmod(callback_path, 0o600) + command_path = root / "preflight.lldb" + command_path.write_text( + "settings set target.preload-symbols false\n" + f"process attach -p {int(pid)}\n" + f"command script import {callback_path}\n" + "wedata_preflight\n" + "quit\n", + encoding="utf-8", + ) + os.chmod(command_path, 0o600) + command = _build_lldb_capture_command(command_path, 45) + output = _run_as_administrator(command, timeout=90) + + try: + payload = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + payload = {} + compact = " ".join(str(output or "").split()).lower() + if "attach failed" in compact or "not allowed to attach" in compact: + _remove_breakpoint_preflight(debug_root) + raise MacOSDBKeyCaptureFailure("lldb_attach_failed", "LLDB 无法附加独立调试微信完成断点预检") + + pbkdf_locations = int(payload.get("pbkdf_locations") or 0) + key_return_locations = int(payload.get("key_return_locations") or 0) + if pbkdf_locations <= 0 and key_return_locations <= 0: + _remove_breakpoint_preflight(debug_root) + raise MacOSDBKeyCaptureFailure( + "capture_breakpoints_unavailable", + "当前微信版本没有可用的密钥捕获断点;监测未启动,请不要退出账号。", + process_attached=True, + ) + return { + "pid": int(payload.get("pid") or pid), + "pbkdf_locations": pbkdf_locations, + "key_return_locations": key_return_locations, + "matched_modules": list(payload.get("matched_modules") or []), + "rejected_points": list(payload.get("rejected_points") or []), + "ready_for_monitoring": True, + "process_attached": True, + "process_detached": True, + } + + +def capture_salt_matched_passphrase( + *, + pid: int, + expected_salts: Iterable[str | bytes], + probe_db_path: str | Path, + timeout: int = 240, + enable_key_return_fallback: bool = True, +) -> str: + if platform.machine().lower() not in {"arm64", "aarch64"}: + raise MacOSDBKeyCaptureFailure("capture_arch_unsupported", "当前安全捕获流程仅支持 Apple Silicon Mac") + if shutil.which("lldb") is None: + raise MacOSDBKeyCaptureFailure("lldb_missing", "未安装 LLDB,请先运行 xcode-select --install") + + salts = _normalize_salts(expected_salts) + probe_database = Path(probe_db_path).expanduser() + try: + with probe_database.open("rb") as handle: + probe_page1 = handle.read(4096) + except OSError as exc: + raise MacOSDBKeyCaptureFailure( + "probe_database_unreadable", + f"无法读取目标数据库用于实时校验: {probe_database}", + ) from exc + if len(probe_page1) < 4096: + raise MacOSDBKeyCaptureFailure("probe_database_invalid", f"目标数据库首页不完整: {probe_database}") + with tempfile.TemporaryDirectory(prefix="wedata-salt-capture-") as temporary_dir: + root = Path(temporary_dir) + result_path = root / "result.json" + result_path.touch(mode=0o600) + callback_path = root / "capture_callback.py" + callback_path.write_text( + build_lldb_salt_capture_script( + result_path, + salts, + probe_page1=probe_page1, + enable_key_return_fallback=enable_key_return_fallback, + ), + encoding="utf-8", + ) + os.chmod(callback_path, 0o600) + command_path = root / "capture.lldb" + command_path.write_text( + "settings set target.preload-symbols false\n" + f"process attach -p {int(pid)}\n" + f"command script import {callback_path}\n" + "wedata_capture\n" + "process continue\n" + "quit\n", + encoding="utf-8", + ) + os.chmod(command_path, 0o600) + command = _build_lldb_capture_command(command_path, timeout) + output = _run_as_administrator(command, timeout=float(timeout + 45)) + try: + payload = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + payload = {} + + passphrase = str(payload.get("passphrase") or "").strip().lower() + captured_salt = str(payload.get("salt") or "").strip().lower() + diagnostics = payload.get("diagnostics") if isinstance(payload.get("diagnostics"), dict) else {} + if len(passphrase) == 64 and captured_salt in salts: + return passphrase + compact = " ".join(str(output or "").split()).lower() + if "attach failed" in compact or "not allowed to attach" in compact: + raise MacOSDBKeyCaptureFailure("lldb_attach_failed", "LLDB 无法附加独立调试微信") + ready_match = re.search(r"wedata_key_monitor_ready\s+(\d+)\s+(\d+)", compact) + if ready_match and int(ready_match.group(1)) <= 0 and int(ready_match.group(2)) <= 0: + raise MacOSDBKeyCaptureFailure( + "capture_breakpoints_unavailable", + "当前微信版本没有可用的密钥捕获断点;监测未启动,请不要退出账号。", + process_attached=True, + ) + pbkdf_calls = int(diagnostics.get("pbkdf_calls") or 0) + pbkdf_shape_hits = int(diagnostics.get("pbkdf_shape_hits") or 0) + pbkdf_salt_hits = int(diagnostics.get("pbkdf_salt_hits") or 0) + key_return_hits = int(diagnostics.get("key_return_hits") or 0) + candidate_rejections = int(diagnostics.get("candidate_rejections") or 0) + detail = ( + f"断点统计:PBKDF2 调用 {pbkdf_calls},参数匹配 {pbkdf_shape_hits}," + f"数据库 salt 匹配 {pbkdf_salt_hits},微信内部断点 {key_return_hits}," + f"候选校验失败 {candidate_rejections}。" + ) + raise MacOSDBKeyCaptureFailure( + "passphrase_not_captured", + "没有捕获到与微信数据库 salt 匹配的 passphrase。" + detail + + "请先在未监测状态下退出到登录界面,再启动监测并重新登录同一账号。", + process_attached=True, + ) + + +def _cleanup_prepared_clone(debug_root: Path) -> None: + try: + state = _read_state(debug_root) + except MacOSDBKeyCaptureFailure: + _remove_breakpoint_preflight(debug_root) + return + debug_app = Path(str(state.get("debug_app_path") or "")) + profile = Path(str(state.get("profile_path") or "")) + if debug_app.is_dir() and debug_app.parent.resolve() == debug_root.resolve(): + _quit_wechat(debug_app) + if _is_safe_clone_profile(profile, debug_root): + _remove_clone_profile(profile, debug_root) + _remove_state(debug_root) + _remove_breakpoint_preflight(debug_root) + + +def prepare_clone_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + del backup_root # The official app is never changed, so no recovery archive is required. + if platform.system().lower() != "darwin": + raise MacOSDBKeyCaptureFailure("unsupported_platform", "LLDB 密钥捕获仅支持 macOS") + wechat_app = normalize_wechat_app_path(wechat_install_path) + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure("official_wechat_untrusted", "正式微信不是有效的腾讯签名版本,已停止操作") + if _find_wechat_main_pid(wechat_app) is not None: + raise MacOSDBKeyCaptureFailure( + "official_wechat_running", + "请先正常退出腾讯原版微信;安全捕获只会启动名称为 WeChat Debug - WCDA 的独立副本。", + ) + + debug_root = debug_root.expanduser() + debug_root.mkdir(parents=True, exist_ok=True) + os.chmod(debug_root, 0o700) + _cleanup_prepared_clone(debug_root) + debug_app, debug_created = _ensure_disposable_debug_copy(wechat_app, debug_root) + # Also recover from a stale state file: a previous debug process must not + # be mistaken for the new profile that will be written below. + _quit_wechat(debug_app) + profile = _clone_real_wechat_container(debug_root) + try: + salts = _collect_database_salts(profile) + debug_pid = _launch_wechat(debug_app, isolated_home=profile) + state_path = _write_state( + debug_root, + { + "debug_app_path": str(debug_app), + "profile_path": str(profile), + "debug_pid": debug_pid, + "database_salts": salts, + }, + ) + except Exception: + _quit_wechat(debug_app) + _remove_clone_profile(profile, debug_root) + raise + + return { + "method": "macos_clone_lldb_prepare", + "wechat_resigned": False, + "wechat_modified": False, + "official_wechat_preserved": True, + "debug_app_path": str(debug_app), + "debug_copy_created": debug_created, + "debug_pid": debug_pid, + "profile_cloned": True, + "matched_salt_count": len(salts), + "state_path": str(state_path), + "process_attached": False, + "ready_for_preflight": True, + "normal_wechat_running": False, + "backup_path": "", + "backup_created": False, + } + + +def cleanup_clone_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + del backup_root + wechat_app = normalize_wechat_app_path(wechat_install_path) + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "official_wechat_untrusted", + "腾讯原版微信签名异常;为避免覆盖应用,已停止自动清理。", + wechat_modified=True, + ) + _cleanup_prepared_clone(debug_root) + return { + "method": "macos_clone_lldb_cancelled", + "wechat_modified": False, + "wechat_resigned": False, + "official_wechat_preserved": True, + "official_wechat_verified": True, + "official_wechat_restored": False, + "normal_wechat_running": _find_wechat_main_pid(wechat_app) is not None, + } + + +def preflight_prepared_clone( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + """Verify breakpoint locations for the prepared Debug process, then detach.""" + + del backup_root + wechat_app = normalize_wechat_app_path(wechat_install_path) + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure("official_wechat_untrusted", "腾讯原版微信签名异常,已停止断点预检") + state = _read_state(debug_root) + debug_app = Path(str(state.get("debug_app_path") or "")) + profile = Path(str(state.get("profile_path") or "")) + if not _is_safe_clone_profile(profile, debug_root) or not _debug_copy_is_ready(debug_app): + raise MacOSDBKeyCaptureFailure("clone_capture_state_invalid", "隔离微信状态无效,请重新准备") + saved_pid = int(state.get("debug_pid") or 0) + debug_pid = _find_wechat_main_pid(debug_app) + if saved_pid <= 0 or debug_pid is None or debug_pid != saved_pid: + raise MacOSDBKeyCaptureFailure("debug_capture_state_stale", "独立微信进程与捕获快照不匹配,请重新准备") + + try: + result = preflight_capture_breakpoints(pid=debug_pid, debug_root=debug_root) + except Exception: + _cleanup_prepared_clone(debug_root) + raise + result.update( + { + "method": "macos_lldb_breakpoint_preflight", + "debug_app_path": str(debug_app), + "official_wechat_preserved": True, + "wechat_modified": False, + "wechat_resigned": False, + } + ) + return result + + +def capture_prepared_clone( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + probe_db_path: str | Path | None, + timeout: int = 240, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + del backup_root + wechat_app = normalize_wechat_app_path(wechat_install_path) + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure("official_wechat_untrusted", "腾讯原版微信签名异常,已停止捕获") + state = _read_state(debug_root) + debug_app = Path(str(state.get("debug_app_path") or "")) + profile = Path(str(state.get("profile_path") or "")) + salts = _normalize_salts(state.get("database_salts") or []) + if not _is_safe_clone_profile(profile, debug_root) or not _debug_copy_is_ready(debug_app): + raise MacOSDBKeyCaptureFailure("clone_capture_state_invalid", "隔离微信状态无效,请重新准备") + saved_pid = int(state.get("debug_pid") or 0) + debug_pid = _find_wechat_main_pid(debug_app) + if debug_pid is None: + _cleanup_prepared_clone(debug_root) + raise MacOSDBKeyCaptureFailure("debug_wechat_not_running", "独立调试微信未运行,请重新准备") + if saved_pid <= 0 or debug_pid != saved_pid: + _cleanup_prepared_clone(debug_root) + raise MacOSDBKeyCaptureFailure("debug_capture_state_stale", "独立微信进程与捕获快照不匹配,请重新准备") + if not probe_db_path: + _cleanup_prepared_clone(debug_root) + raise MacOSDBKeyCaptureFailure("probe_database_required", "必须提供目标数据库用于校验捕获结果") + + preflight_path = _breakpoint_preflight_path(debug_root) + try: + preflight = json.loads(preflight_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + _cleanup_prepared_clone(debug_root) + raise MacOSDBKeyCaptureFailure( + "capture_preflight_required", + "尚未完成断点预检;监测未启动,请重新准备并先执行预检。", + ) + if ( + int(preflight.get("pid") or 0) != debug_pid + or ( + int(preflight.get("pbkdf_locations") or 0) <= 0 + and int(preflight.get("key_return_locations") or 0) <= 0 + ) + ): + _cleanup_prepared_clone(debug_root) + raise MacOSDBKeyCaptureFailure( + "capture_preflight_stale", + "断点预检结果与当前独立微信进程不匹配;监测未启动,请重新准备。", + ) + + cache_path: Path | None = None + try: + passphrase = capture_salt_matched_passphrase( + pid=debug_pid, + expected_salts=salts, + probe_db_path=probe_db_path, + timeout=timeout, + # Monitoring starts only after the account is already at the QR + # screen, so it is safe to arm both validated locations. On + # WeChat 4.1.12 the public CommonCrypto symbol resolves but is not + # necessarily called again by every re-login path; the verified + # internal return point is therefore required as a live fallback. + enable_key_return_fallback=int(preflight.get("key_return_locations") or 0) > 0, + ) + _validate_captured_passphrase(passphrase, probe_db_path) + cache_path = save_passphrase(passphrase) + finally: + _cleanup_prepared_clone(debug_root) + if cache_path is None: + raise MacOSDBKeyCaptureFailure("passphrase_not_saved", "passphrase 未能安全保存") + return { + "method": "macos_clone_lldb_passphrase", + "cache_path": str(cache_path), + "wechat_modified": False, + "wechat_resigned": False, + "official_wechat_preserved": True, + "official_wechat_verified": True, + "official_wechat_restored": False, + "debug_app_path": str(debug_app), + "debug_copy_created": False, + "profile_cloned": True, + "process_attached": True, + "normal_wechat_running": False, + "backup_path": "", + "backup_created": False, + } + + +__all__ = [ + "build_lldb_breakpoint_preflight_script", + "build_lldb_salt_capture_script", + "capture_prepared_clone", + "capture_salt_matched_passphrase", + "cleanup_clone_capture", + "preflight_capture_breakpoints", + "preflight_prepared_clone", + "prepare_clone_capture", +] diff --git a/src/wechat_decrypt_tool/macos_db_key_capture.py b/src/wechat_decrypt_tool/macos_db_key_capture.py new file mode 100644 index 00000000..c35b0bf7 --- /dev/null +++ b/src/wechat_decrypt_tool/macos_db_key_capture.py @@ -0,0 +1,1157 @@ +"""macOS WeChat 4.1+ passphrase capture with explicit administrator approval. + +The LLDB breakpoint and register selection are adapted from +TANGandXUE/wcdb-key-tool (MIT). WeChat is temporarily re-signed only after a +Tencent-signed, version-matched backup has been verified. The higher-level +capture workflow records recovery state before that mutation and restores the +official application after success, failure, cancellation, or the next launch. +""" + +from __future__ import annotations + +import ctypes +import json +import os +import platform +import plistlib +import re +import shlex +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +DEFAULT_WECHAT_APP = Path("/Applications/WeChat.app") +DEFAULT_DEBUG_ROOT = Path.home() / "Library/Caches/WeChatDataAnalysis/wechat-debug" +LOCAL_RESTORE_STAGING_NAME = ".WeChat.wedata-official-restore.app" +DEBUG_COPY_DISPLAY_NAME = "WeChat Debug - WCDA" +DEBUG_LOGIN_HELPER = Path("Contents/MacOS/WeChatAppEx.app") +PASSPHRASE_RELATIVE_PATH = Path(".wcdb-key-tool/wechat-passphrase.json") +_PASSPHRASE_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_MEMORY_LINE_RE = re.compile(r"\s*0x[0-9a-fA-F]+:\s+((?:0x[0-9a-fA-F]{2}\s*)+)$") + + +@dataclass(frozen=True, slots=True) +class MacOSDBKeyCaptureFailure(RuntimeError): + code: str + message: str + requires_wechat_resign: bool = False + wechat_modified: bool = False + process_attached: bool = False + + def __str__(self) -> str: + return self.message + + +def _run( + args: list[str], + *, + timeout: float = 30, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + check=check, + ) + except subprocess.TimeoutExpired as exc: + raise MacOSDBKeyCaptureFailure("command_timeout", f"命令执行超时: {args[0]}") from exc + except subprocess.CalledProcessError as exc: + detail = " ".join(str(exc.stderr or exc.stdout or "").split())[-600:] + raise MacOSDBKeyCaptureFailure( + "command_failed", + f"命令执行失败: {Path(args[0]).name}{(': ' + detail) if detail else ''}", + ) from exc + + +def _run_as_administrator(command: str, *, timeout: float) -> str: + apple_script = ( + "on run argv\n" + "do shell script (item 1 of argv) with administrator privileges\n" + "end run" + ) + try: + result = subprocess.run( + ["/usr/bin/osascript", "-e", apple_script, command], + capture_output=True, + text=True, + timeout=timeout, + check=True, + ) + except subprocess.TimeoutExpired as exc: + raise MacOSDBKeyCaptureFailure("administrator_timeout", "管理员授权或密钥捕获等待超时") from exc + except subprocess.CalledProcessError as exc: + detail = " ".join(str(exc.stderr or exc.stdout or "").split())[-600:] + code = "administrator_cancelled" if "User canceled" in detail or "-128" in detail else "administrator_failed" + message = "已取消管理员授权" if code == "administrator_cancelled" else f"管理员操作失败: {detail or '未知错误'}" + raise MacOSDBKeyCaptureFailure(code, message) from exc + return str(result.stdout or "") + + +def normalize_wechat_app_path(value: str | Path | None) -> Path: + candidate = Path(value or DEFAULT_WECHAT_APP).expanduser() + if candidate.name == "WeChat" and candidate.parent.name == "MacOS": + candidate = candidate.parents[2] + try: + candidate = candidate.resolve(strict=True) + except OSError as exc: + raise MacOSDBKeyCaptureFailure("wechat_missing", f"微信应用不存在: {candidate}") from exc + if candidate.suffix.lower() != ".app" or not candidate.is_dir(): + raise MacOSDBKeyCaptureFailure("invalid_wechat_app", f"不是有效的微信 App: {candidate}") + + info_path = candidate / "Contents/Info.plist" + executable = candidate / "Contents/MacOS/WeChat" + try: + info = plistlib.loads(info_path.read_bytes()) + except (OSError, plistlib.InvalidFileException) as exc: + raise MacOSDBKeyCaptureFailure("invalid_wechat_app", f"无法读取微信 Info.plist: {candidate}") from exc + if str(info.get("CFBundleIdentifier") or "") != "com.tencent.xinWeChat" or not executable.is_file(): + raise MacOSDBKeyCaptureFailure("invalid_wechat_app", f"应用身份不是 com.tencent.xinWeChat: {candidate}") + return candidate + + +def inspect_wechat_signature(wechat_app: Path) -> dict[str, Any]: + result = _run(["/usr/bin/codesign", "-dvvv", str(wechat_app)], check=False) + output = f"{result.stdout}\n{result.stderr}" + valid = subprocess.run( + ["/usr/bin/codesign", "--verify", "--deep", "--strict", str(wechat_app)], + capture_output=True, + text=True, + check=False, + ).returncode == 0 + hardened_runtime = bool(re.search(r"flags=.*\bruntime\b", output)) + ad_hoc = "Signature=adhoc" in output or bool(re.search(r"flags=0x[0-9a-fA-F]+\(adhoc", output)) + team_match = re.search(r"^TeamIdentifier=([^\s]+)", output, re.MULTILINE) + identifier_match = re.search(r"^Identifier=([^\s]+)", output, re.MULTILINE) + cdhash_match = re.search(r"^CDHash=([0-9a-fA-F]+)", output, re.MULTILINE) + team_identifier = "" if not team_match or team_match.group(1) == "not" else team_match.group(1) + return { + "valid": valid, + "hardened_runtime": hardened_runtime, + "ad_hoc": ad_hoc, + "team_identifier": team_identifier, + "identifier": identifier_match.group(1) if identifier_match else "", + "cdhash": cdhash_match.group(1).lower() if cdhash_match else "", + "requires_resign": (not valid) or hardened_runtime or not ad_hoc, + } + + +def _is_tencent_official_signature(signature: dict[str, Any]) -> bool: + return bool( + signature.get("valid") + and not signature.get("ad_hoc") + and signature.get("team_identifier") == "5A4RE8SF68" + and signature.get("identifier") == "com.tencent.xinWeChat" + ) + + +def _wechat_version(wechat_app: Path) -> tuple[str, str]: + info = plistlib.loads((wechat_app / "Contents/Info.plist").read_bytes()) + safe = lambda value: re.sub(r"[^0-9A-Za-z._-]+", "_", str(value or "unknown")) + return safe(info.get("CFBundleShortVersionString")), safe(info.get("CFBundleVersion")) + + +def _original_backup_path(wechat_app: Path, backup_root: Path) -> Path: + version, build = _wechat_version(wechat_app) + return backup_root.expanduser() / f"WeChat-{version}-{build}-original.zip" + + +def backup_original_wechat(wechat_app: Path, backup_root: Path) -> tuple[Path, bool]: + version, build = _wechat_version(wechat_app) + backup_root = backup_root.expanduser() + backup_root.mkdir(parents=True, exist_ok=True) + # App bundles copied directly to some external filesystems can lose + # signature-related metadata. A ditto ZIP with sequestered resource forks + # restores the original bundle faithfully on macOS. + final_path = backup_root / f"WeChat-{version}-{build}-original.zip" + if final_path.is_file(): + valid = subprocess.run( + ["/usr/bin/unzip", "-tqq", str(final_path)], + capture_output=True, + text=True, + check=False, + ).returncode == 0 + if valid and final_path.stat().st_size > 0: + return final_path, False + raise MacOSDBKeyCaptureFailure("backup_invalid", f"已有微信备份压缩包不完整,请人工检查: {final_path}") + + staging = backup_root / f".{final_path.name}.copying" + if staging.exists(): + staging.unlink() + try: + _run( + [ + "/usr/bin/ditto", + "-c", + "-k", + "--sequesterRsrc", + "--keepParent", + str(wechat_app), + str(staging), + ], + timeout=1800, + ) + _run(["/usr/bin/unzip", "-tqq", str(staging)], timeout=600) + staging.replace(final_path) + except Exception: + if staging.exists(): + staging.unlink() + raise + return final_path, True + + +def verify_original_wechat_backup( + backup_path: Path, + *, + expected_version: tuple[str, str] | None = None, + work_root: Path | None = None, +) -> dict[str, Any]: + """Extract and verify the selected backup archive before changing the installed app.""" + + backup_path = backup_path.expanduser() + if not backup_path.is_file() or backup_path.stat().st_size <= 0: + raise MacOSDBKeyCaptureFailure("backup_missing", f"找不到微信原版备份: {backup_path}") + _run(["/usr/bin/unzip", "-tqq", str(backup_path)], timeout=600) + + verify_root = (work_root or DEFAULT_DEBUG_ROOT).expanduser() + verify_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="verify-backup-", dir=str(verify_root)) as temp_dir: + extract_root = Path(temp_dir) / "extract" + extract_root.mkdir() + _run(["/usr/bin/ditto", "-x", "-k", str(backup_path), str(extract_root)], timeout=1800) + extracted_app = normalize_wechat_app_path(extract_root / "WeChat.app") + signature = inspect_wechat_signature(extracted_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "official_backup_untrusted", + "所选目录中的微信原版备份不是有效的腾讯签名版本,已停止临时重签。", + ) + version = _wechat_version(extracted_app) + if expected_version is not None and version != expected_version: + raise MacOSDBKeyCaptureFailure( + "official_backup_version_mismatch", + "所选目录中的微信原版备份版本与当前安装版本不一致,已停止临时重签。", + ) + return { + "backup_path": str(backup_path), + "backup_size": backup_path.stat().st_size, + "version": version[0], + "build": version[1], + "team_identifier": signature.get("team_identifier", ""), + "cdhash": signature.get("cdhash", ""), + "verified": True, + } + + +def _local_restore_staging_path(wechat_app: Path) -> Path: + return wechat_app.with_name(LOCAL_RESTORE_STAGING_NAME) + + +def _remove_local_restore_staging(staged: Path) -> None: + if not os.path.lexists(staged): + return + if staged.is_symlink() or not staged.is_dir(): + raise MacOSDBKeyCaptureFailure( + "local_restore_path_unsafe", + f"原版微信保护路径类型异常,已停止自动覆盖: {staged}", + wechat_modified=True, + ) + shutil.rmtree(staged) + + +def _prepare_local_restore_staging( + wechat_app: Path, + *, + expected_version: tuple[str, str], + expected_cdhash: str, +) -> Path: + """Create a same-volume APFS clone that survives a temporary backup-volume outage.""" + + staged = _local_restore_staging_path(wechat_app) + _remove_local_restore_staging(staged) + library = ctypes.CDLL(None, use_errno=True) + clonefile = getattr(library, "clonefile", None) + if clonefile is None: + raise MacOSDBKeyCaptureFailure("local_restore_clone_unavailable", "当前 macOS 不支持原版微信写时复制保护") + clonefile.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] + clonefile.restype = ctypes.c_int + if clonefile(os.fsencode(wechat_app), os.fsencode(staged), 0) != 0: + error_number = ctypes.get_errno() + if os.path.lexists(staged): + _remove_local_restore_staging(staged) + raise MacOSDBKeyCaptureFailure( + "local_restore_clone_failed", + f"无法创建原版微信写时复制保护: {os.strerror(error_number)}", + ) + signature = inspect_wechat_signature(staged) + if ( + not _is_tencent_official_signature(signature) + or _wechat_version(staged) != expected_version + or (expected_cdhash and str(signature.get("cdhash") or "").lower() != expected_cdhash.lower()) + ): + _remove_local_restore_staging(staged) + raise MacOSDBKeyCaptureFailure("local_restore_clone_invalid", "原版微信写时复制保护校验失败,已停止临时重签") + return staged + + +def _find_wechat_pid() -> int | None: + result = subprocess.run(["/usr/bin/pgrep", "-x", "WeChat"], capture_output=True, text=True, check=False) + for value in str(result.stdout or "").split(): + if value.isdigit(): + return int(value) + return None + + +def _find_wechat_main_pid(wechat_app: Path) -> int | None: + executable = str(wechat_app / "Contents/MacOS/WeChat") + result = subprocess.run(["/usr/bin/pgrep", "-f", f"^{re.escape(executable)}$"], capture_output=True, text=True, check=False) + for value in str(result.stdout or "").split(): + if value.isdigit(): + return int(value) + return None + + +def _find_wechat_bundle_pids(wechat_app: Path) -> list[int]: + pattern = "^" + re.escape(str(wechat_app / "Contents")) + "/" + result = subprocess.run(["/usr/bin/pgrep", "-f", pattern], capture_output=True, text=True, check=False) + return [int(value) for value in str(result.stdout or "").split() if value.isdigit()] + + +def _quit_wechat(wechat_app: Path, timeout: float = 30, *, force_for_restore: bool = False) -> None: + pid = _find_wechat_main_pid(wechat_app) + if pid is None: + # A matched LLDB callback can kill the main process before this + # cleanup hook runs. Reap any remaining helpers belonging to this + # disposable bundle so they cannot keep the profile open. + remaining = _find_wechat_bundle_pids(wechat_app) + if not remaining: + return + subprocess.run(["/bin/kill", "-TERM", *(str(value) for value in remaining)], capture_output=True, text=True, check=False) + deadline = time.monotonic() + min(timeout, 10) + while time.monotonic() < deadline: + remaining = _find_wechat_bundle_pids(wechat_app) + if not remaining: + return + time.sleep(0.25) + remaining = _find_wechat_bundle_pids(wechat_app) + if remaining: + subprocess.run(["/bin/kill", "-KILL", *(str(value) for value in remaining)], capture_output=True, text=True, check=False) + return + if wechat_app == DEFAULT_WECHAT_APP: + try: + subprocess.run( + ["/usr/bin/osascript", "-e", 'tell application id "com.tencent.xinWeChat" to quit'], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + # An ad-hoc in-place build can stop servicing AppleEvents while its + # login window is open. Fall through to the bounded SIGTERM path + # instead of blocking restoration of the Tencent-signed backup. + pass + else: + subprocess.run(["/bin/kill", "-TERM", str(pid)], capture_output=True, text=True, check=False) + graceful_deadline = time.monotonic() + min(timeout, 15) + while time.monotonic() < graceful_deadline: + if _find_wechat_main_pid(wechat_app) is None: + break + time.sleep(0.25) + + # WeChat can ignore the AppleEvent when a secondary window is busy. A + # normal SIGTERM is still a controlled application shutdown and is safer + # than signing while any process has the bundle open. + current_pid = _find_wechat_main_pid(wechat_app) + if current_pid: + subprocess.run(["/bin/kill", "-TERM", str(current_pid)], capture_output=True, text=True, check=False) + deadline = time.monotonic() + max(5, timeout - 15) + while time.monotonic() < deadline: + if _find_wechat_main_pid(wechat_app) is None: + break + time.sleep(0.25) + if _find_wechat_main_pid(wechat_app) is not None: + if not force_for_restore: + raise MacOSDBKeyCaptureFailure("wechat_still_running", "微信未能正常退出,请手动退出微信后重试") + # LLDB/debugserver can leave the temporary process stopped. SIGTERM is + # then only pending and cannot complete before restoration. This + # force path is reserved for restoring the verified official bundle, + # and targets only PIDs whose executable lives inside this exact app. + remaining_main = _find_wechat_main_pid(wechat_app) + if remaining_main: + subprocess.run(["/bin/kill", "-CONT", str(remaining_main)], capture_output=True, text=True, check=False) + subprocess.run(["/bin/kill", "-KILL", str(remaining_main)], capture_output=True, text=True, check=False) + forced_deadline = time.monotonic() + 5 + while time.monotonic() < forced_deadline: + if _find_wechat_main_pid(wechat_app) is None: + break + time.sleep(0.25) + if _find_wechat_main_pid(wechat_app) is not None: + raise MacOSDBKeyCaptureFailure("wechat_force_stop_failed", "无法结束临时调试微信,已停止替换应用") + + helper_deadline = time.monotonic() + 10 + while time.monotonic() < helper_deadline: + remaining = _find_wechat_bundle_pids(wechat_app) + if not remaining: + return + time.sleep(0.25) + remaining = _find_wechat_bundle_pids(wechat_app) + if remaining: + subprocess.run(["/bin/kill", "-TERM", *(str(pid) for pid in remaining)], capture_output=True, text=True, check=False) + final_deadline = time.monotonic() + 5 + while time.monotonic() < final_deadline: + remaining = _find_wechat_bundle_pids(wechat_app) + if not remaining: + return + time.sleep(0.25) + remaining = _find_wechat_bundle_pids(wechat_app) + if remaining: + subprocess.run(["/bin/kill", "-KILL", *(str(pid) for pid in remaining)], capture_output=True, text=True, check=False) + + +def _launch_wechat( + wechat_app: Path, + timeout: float = 45, + *, + isolated_home: Path | None = None, +) -> int: + if isolated_home is None: + _run(["/usr/bin/open", "-n", str(wechat_app)], timeout=15) + else: + # An ad-hoc build is intentionally not allowed to read Tencent's + # protected app container. Launch the disposable copy with a private + # Foundation home instead. This gives WeChat a working login UI while + # keeping the user's normal account data completely out of scope. + isolated_home.mkdir(parents=True, exist_ok=True) + os.chmod(isolated_home, 0o700) + temp_root = isolated_home / "tmp" + temp_root.mkdir(parents=True, exist_ok=True) + os.chmod(temp_root, 0o700) + environment = os.environ.copy() + environment.update( + { + "HOME": str(isolated_home), + "CFFIXED_USER_HOME": str(isolated_home), + "TMPDIR": f"{temp_root}{os.sep}", + } + ) + try: + subprocess.Popen( + [str(wechat_app / "Contents/MacOS/WeChat")], + cwd=str(isolated_home), + env=environment, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + raise MacOSDBKeyCaptureFailure( + "wechat_launch_failed", + f"未能启动隔离微信副本: {wechat_app}", + ) from exc + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + pid = _find_wechat_main_pid(wechat_app) + if pid: + time.sleep(2) + stable_pid = _find_wechat_main_pid(wechat_app) + if stable_pid: + return stable_pid + time.sleep(0.25) + raise MacOSDBKeyCaptureFailure("wechat_launch_failed", f"未能启动微信副本: {wechat_app}") + + +def _has_compatible_debug_entitlements(wechat_app: Path) -> bool: + # Tencent's app group and sandbox container are valid only for Tencent's + # Developer ID. Preserving any of those claims on an ad-hoc signature + # produces a window that is visible but whose login controls do not work. + # The outer app and the WeChatAppEx login/verification helper must both be + # entitlement-free. Otherwise the QR window appears, but WeChatAppEx dies + # in libsecinit before it can display the phone-confirmation/code screen. + for target in (wechat_app, wechat_app / DEBUG_LOGIN_HELPER): + if not target.exists(): + return False + result = _run(["/usr/bin/codesign", "-d", "--entitlements", ":-", str(target)], check=False) + output = f"{result.stdout}\n{result.stderr}" + if "" in output: + return False + return True + + +def _has_compatible_in_place_signature(wechat_app: Path) -> bool: + """Allow LLDB without removing permissions required by WeChat 4.1.12. + + The installed build is sandboxed. Dropping the outer bundle's original + entitlements lets the process start, but it exits immediately after its + app-group/container initialization. Only the outer executable is signed + ad-hoc; its original permissions are retained and the login helper keeps + its untouched Tencent signature. + """ + + outer_entitlements = _run( + ["/usr/bin/codesign", "-d", "--entitlements", ":-", str(wechat_app)], + check=False, + ) + entitlement_output = f"{outer_entitlements.stdout}\n{outer_entitlements.stderr}" + required_entitlements = ( + "com.apple.application-identifier", + "5A4RE8SF68.com.tencent.xinWeChat", + "com.apple.security.app-sandbox", + "com.apple.security.application-groups", + "com.apple.security.network.client", + ) + if any(value not in entitlement_output for value in required_entitlements): + return False + + helper = wechat_app / DEBUG_LOGIN_HELPER + if not helper.exists(): + return False + helper_signature = inspect_wechat_signature(helper) + return bool( + helper_signature.get("valid") + and not helper_signature.get("ad_hoc") + and helper_signature.get("team_identifier") == "5A4RE8SF68" + and helper_signature.get("identifier") == "com.tencent.flue.WeChatAppEx" + ) + + +def _debug_copy_path(wechat_app: Path, debug_root: Path) -> Path: + version, build = _wechat_version(wechat_app) + return debug_root.expanduser() / f"WeChat-{version}-{build}-Debug.app" + + +def _has_debug_copy_marker(wechat_app: Path) -> bool: + try: + info = plistlib.loads((wechat_app / "Contents/Info.plist").read_bytes()) + except (OSError, plistlib.InvalidFileException): + return False + marked = bool( + info.get("WeDataDebugCopy") is True + and info.get("CFBundleDisplayName") == DEBUG_COPY_DISPLAY_NAME + and info.get("CFBundleName") == DEBUG_COPY_DISPLAY_NAME + ) + if not marked: + return False + for strings_path in (wechat_app / "Contents/Resources").glob("*.lproj/InfoPlist.strings"): + try: + localized = plistlib.loads(strings_path.read_bytes()) + except (OSError, plistlib.InvalidFileException): + return False + if ( + localized.get("CFBundleDisplayName") != DEBUG_COPY_DISPLAY_NAME + or localized.get("CFBundleName") != DEBUG_COPY_DISPLAY_NAME + ): + return False + return True + + +def _mark_debug_copy(wechat_app: Path) -> None: + info_path = wechat_app / "Contents/Info.plist" + try: + info = plistlib.loads(info_path.read_bytes()) + info["CFBundleDisplayName"] = DEBUG_COPY_DISPLAY_NAME + info["CFBundleName"] = DEBUG_COPY_DISPLAY_NAME + info["WeDataDebugCopy"] = True + info_path.write_bytes(plistlib.dumps(info, fmt=plistlib.FMT_BINARY, sort_keys=False)) + for strings_path in (wechat_app / "Contents/Resources").glob("*.lproj/InfoPlist.strings"): + try: + localized = plistlib.loads(strings_path.read_bytes()) + except plistlib.InvalidFileException: + # Tencent ships localized InfoPlist.strings in the legacy + # OpenStep format. plistlib cannot parse it, but macOS plutil + # can safely normalize it before the disposable copy is signed. + _run(["/usr/bin/plutil", "-convert", "binary1", str(strings_path)]) + localized = plistlib.loads(strings_path.read_bytes()) + localized["CFBundleDisplayName"] = DEBUG_COPY_DISPLAY_NAME + localized["CFBundleName"] = DEBUG_COPY_DISPLAY_NAME + strings_path.write_bytes(plistlib.dumps(localized, fmt=plistlib.FMT_BINARY, sort_keys=False)) + except (OSError, plistlib.InvalidFileException, MacOSDBKeyCaptureFailure) as exc: + raise MacOSDBKeyCaptureFailure( + "debug_copy_mark_failed", + f"无法标记微信调试副本: {exc}", + ) from exc + + +def _prepare_debug_copy(wechat_app: Path, backup_path: Path, debug_root: Path) -> tuple[Path, bool]: + debug_root = debug_root.expanduser() + debug_root.mkdir(parents=True, exist_ok=True) + debug_app = _debug_copy_path(wechat_app, debug_root) + if debug_app.exists(): + signature = inspect_wechat_signature(debug_app) + if ( + signature["valid"] + and signature["ad_hoc"] + and not signature["hardened_runtime"] + and _has_compatible_debug_entitlements(debug_app) + and _has_debug_copy_marker(debug_app) + ): + return debug_app, False + shutil.rmtree(debug_app) + + with tempfile.TemporaryDirectory(prefix="prepare-", dir=str(debug_root)) as temp_dir: + extract_root = Path(temp_dir) / "extract" + extract_root.mkdir() + _run(["/usr/bin/ditto", "-x", "-k", str(backup_path), str(extract_root)], timeout=1800) + extracted_app = extract_root / "WeChat.app" + normalize_wechat_app_path(extracted_app) + shutil.move(str(extracted_app), str(debug_app)) + + # Keep the bundle identifier for application compatibility, but make this + # disposable process visually unmistakable in the Dock and app switcher. + _mark_debug_copy(debug_app) + + # The backup ZIP may retain the original download quarantine. The debug copy + # is derived locally from an already verified Tencent-signed application, + # so remove quarantine only from this disposable copy before re-signing. + # A Tencent-owned read-only shader cache can reject xattr deletion even in + # the copied bundle. xattr still clears the bundle/main-executable marker, + # which is what LaunchServices evaluates, so tolerate per-file warnings. + _run(["/usr/bin/xattr", "-dr", "com.apple.quarantine", str(debug_app)], timeout=300, check=False) + # WeChat's post-scan confirmation UI is hosted by WeChatAppEx. It cannot + # initialize its original Tencent sandbox while embedded in an ad-hoc + # parent bundle, so apply the same entitlement-free ad-hoc identity to the + # complete disposable copy. The official installation remains untouched. + _run( + [ + "/usr/bin/codesign", + "--force", + "--deep", + "--sign", + "-", + str(debug_app), + ], + timeout=300, + ) + signature = inspect_wechat_signature(debug_app) + if ( + not signature["valid"] + or signature["hardened_runtime"] + or not signature["ad_hoc"] + or not _has_compatible_debug_entitlements(debug_app) + ): + raise MacOSDBKeyCaptureFailure("debug_copy_sign_failed", "微信调试副本签名或 entitlement 校验失败") + return debug_app, True + + +def ensure_wechat_debuggable( + wechat_app: Path, + backup_root: Path, + *, + debug_root: Path | None = None, +) -> dict[str, Any]: + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure("official_wechat_untrusted", "正式微信不是有效的腾讯签名版本,已停止自动处理") + backup_path, backup_created = backup_original_wechat(wechat_app, backup_root) + debug_app, debug_created = _prepare_debug_copy( + wechat_app, + backup_path, + debug_root or DEFAULT_DEBUG_ROOT, + ) + return { + "wechat_resigned": False, + "wechat_modified": False, + "official_wechat_preserved": True, + "debug_app_path": str(debug_app), + "debug_copy_created": debug_created, + "backup_path": str(backup_path), + "backup_created": backup_created, + } + + +def ensure_wechat_in_place_debuggable( + wechat_app: Path, + backup_root: Path, + *, + before_resign: Any | None = None, +) -> dict[str, Any]: + """Temporarily ad-hoc sign WeChat at its original application path. + + WeChat 4.1.12 exits immediately when an ad-hoc copy outside + ``/Applications/WeChat.app`` tries to use Tencent's normal container. The + upstream wcdb-key-tool therefore signs the installed path in place. Keep + that mutation recoverable by requiring a verified Tencent backup first and + restoring it after capture, cancellation, or launch failure. + """ + + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "wechat_signature_untrusted", + "当前 /Applications/WeChat.app 不是有效的腾讯正式签名版本,已停止临时重签。", + wechat_modified=bool(signature.get("ad_hoc")), + ) + if not os.access(wechat_app, os.W_OK) or not os.access(wechat_app.parent, os.W_OK): + raise MacOSDBKeyCaptureFailure( + "in_place_restore_permissions_unsafe", + "当前用户不能直接写入微信及其安装目录;为保证异常退出后无需再次授权也能恢复原版,已停止临时重签。", + ) + + backup_path, backup_created = backup_original_wechat(wechat_app, backup_root) + backup_verification = verify_original_wechat_backup( + backup_path, + expected_version=_wechat_version(wechat_app), + ) + original_cdhash = str(signature.get("cdhash") or "").lower() + backup_cdhash = str(backup_verification.get("cdhash") or "").lower() + if original_cdhash and backup_cdhash != original_cdhash: + raise MacOSDBKeyCaptureFailure( + "official_backup_identity_mismatch", + "所选目录中的原版备份虽有腾讯签名,但与当前安装的微信不是同一构建,已停止临时重签。", + ) + + recovery = { + "wechat_app_path": str(wechat_app), + "backup_path": str(backup_path), + "backup_created": backup_created, + "version": backup_verification["version"], + "build": backup_verification["build"], + "official_cdhash": backup_cdhash, + } + if before_resign is not None: + before_resign(dict(recovery)) + + try: + _quit_wechat(wechat_app) + staged_app = _prepare_local_restore_staging( + wechat_app, + expected_version=(str(recovery["version"]), str(recovery["build"])), + expected_cdhash=str(recovery["official_cdhash"]), + ) + # Sign the same-volume APFS clone, not the application path that was + # just used by LaunchServices. Newer macOS releases can transiently + # deny signature replacement on that recently exited path even when + # it is writable. The atomic exchange below installs the verified + # debug clone while moving the untouched official bundle into the + # recovery slot. + sign_command = [ + "/usr/bin/codesign", + "--force", + "--preserve-metadata=entitlements", + "--sign", + "-", + str(staged_app), + ] + try: + _run(sign_command, timeout=300) + except MacOSDBKeyCaptureFailure: + # App Store / managed installations can be root-owned. Match the + # upstream prerequisite while keeping the exact target quoted. + _run_as_administrator(shlex.join(sign_command), timeout=345) + signature = inspect_wechat_signature(staged_app) + if ( + not signature.get("valid") + or not signature.get("ad_hoc") + or signature.get("hardened_runtime") + or not _has_compatible_in_place_signature(staged_app) + ): + raise MacOSDBKeyCaptureFailure( + "in_place_sign_failed", + "临时调试签名校验失败。", + requires_wechat_resign=True, + wechat_modified=True, + ) + _atomic_swap_paths(wechat_app, staged_app) + installed_signature = inspect_wechat_signature(wechat_app) + if ( + not installed_signature.get("valid") + or not installed_signature.get("ad_hoc") + or installed_signature.get("hardened_runtime") + or not _has_compatible_in_place_signature(wechat_app) + ): + raise MacOSDBKeyCaptureFailure( + "in_place_swap_verify_failed", + "临时微信原子安装后的签名校验失败。", + requires_wechat_resign=True, + wechat_modified=True, + ) + except Exception as capture_error: + try: + restore_official_wechat_if_needed( + wechat_app, + backup_path, + expected_version=(str(recovery["version"]), str(recovery["build"])), + expected_cdhash=str(recovery["official_cdhash"]), + ) + except Exception as restore_error: + raise MacOSDBKeyCaptureFailure( + "official_restore_failed", + f"临时重签失败,且自动恢复腾讯原版微信失败: {restore_error}", + requires_wechat_resign=True, + wechat_modified=True, + ) from restore_error + raise capture_error + + return { + "wechat_resigned": True, + "wechat_modified": True, + "official_wechat_preserved": False, + "debug_app_path": str(wechat_app), + "debug_copy_created": False, + "debug_in_place": True, + "backup_path": str(backup_path), + "backup_created": backup_created, + "backup_verified": True, + "official_cdhash": backup_cdhash, + } + + +def restore_official_wechat_if_needed( + wechat_app: Path, + backup_path: Path, + *, + work_root: Path | None = None, + expected_version: tuple[str, str] | None = None, + expected_cdhash: str | None = None, +) -> dict[str, Any]: + """Verify the normal Tencent build and restore it atomically when needed.""" + + staged = _local_restore_staging_path(wechat_app) + current = inspect_wechat_signature(wechat_app) + if _is_tencent_official_signature(current): + # Recovery may have been interrupted just after the atomic exchange. + # At that point the installed path is already official and ``staged`` + # contains only WCDA's displaced ad-hoc bundle. + _remove_local_restore_staging(staged) + return {"official_wechat_verified": True, "official_wechat_restored": False} + + use_local_staging = False + if os.path.lexists(staged): + if staged.is_symlink() or not staged.is_dir(): + raise MacOSDBKeyCaptureFailure( + "local_restore_path_unsafe", + f"原版微信保护路径类型异常,已停止自动覆盖: {staged}", + wechat_modified=True, + ) + try: + staged_signature = inspect_wechat_signature(staged) + staged_version = _wechat_version(staged) + staged_cdhash = str(staged_signature.get("cdhash") or "").lower() + use_local_staging = bool( + _is_tencent_official_signature(staged_signature) + and (expected_version is None or staged_version == expected_version) + and (not expected_cdhash or staged_cdhash == str(expected_cdhash).lower()) + ) + except (OSError, plistlib.InvalidFileException, MacOSDBKeyCaptureFailure): + use_local_staging = False + if not use_local_staging: + _remove_local_restore_staging(staged) + + if not use_local_staging: + restore_root = (work_root or DEFAULT_DEBUG_ROOT).expanduser() + restore_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="restore-", dir=str(restore_root)) as temp_dir: + extract_root = Path(temp_dir) / "extract" + extract_root.mkdir() + _run(["/usr/bin/ditto", "-x", "-k", str(backup_path), str(extract_root)], timeout=1800) + extracted_app = normalize_wechat_app_path(extract_root / "WeChat.app") + restored_signature = inspect_wechat_signature(extracted_app) + if not _is_tencent_official_signature(restored_signature): + raise MacOSDBKeyCaptureFailure("official_backup_untrusted", "所选目录中的微信原版备份签名校验失败,已停止恢复") + restored_version = _wechat_version(extracted_app) + if expected_version is not None and restored_version != expected_version: + raise MacOSDBKeyCaptureFailure("official_backup_version_mismatch", "所选目录中的微信原版备份版本不匹配,已停止恢复") + restored_cdhash = str(restored_signature.get("cdhash") or "").lower() + if expected_cdhash and restored_cdhash != str(expected_cdhash).lower(): + raise MacOSDBKeyCaptureFailure("official_backup_identity_mismatch", "所选目录中的微信原版备份身份不匹配,已停止恢复") + try: + _run(["/usr/bin/ditto", str(extracted_app), str(staged)], timeout=1800) + except Exception: + if os.path.lexists(staged): + _remove_local_restore_staging(staged) + raise + staged_signature = inspect_wechat_signature(staged) + if not _is_tencent_official_signature(staged_signature): + _remove_local_restore_staging(staged) + raise MacOSDBKeyCaptureFailure("official_restore_staging_invalid", "恢复暂存的腾讯微信签名校验失败") + + _quit_wechat(wechat_app, force_for_restore=True) + swapped = False + try: + _atomic_swap_paths(wechat_app, staged) + swapped = True + installed = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(installed): + raise MacOSDBKeyCaptureFailure("official_restore_verify_failed", "恢复后的腾讯微信签名校验失败") + except Exception: + if swapped: + try: + _atomic_swap_paths(wechat_app, staged) + swapped = False + except Exception as rollback_error: + raise MacOSDBKeyCaptureFailure( + "official_restore_rollback_failed", + f"微信原版恢复校验失败,且无法回滚交换: {rollback_error}", + wechat_modified=True, + ) from rollback_error + if os.path.lexists(staged): + _remove_local_restore_staging(staged) + raise + _remove_local_restore_staging(staged) + + return {"official_wechat_verified": True, "official_wechat_restored": True} + + +def _atomic_swap_paths(first: Path, second: Path) -> None: + """Atomically exchange two same-volume paths using renameatx_np(2).""" + + library = ctypes.CDLL(None, use_errno=True) + renameatx_np = getattr(library, "renameatx_np", None) + if renameatx_np is None: + raise MacOSDBKeyCaptureFailure("atomic_restore_unavailable", "当前 macOS 不支持微信原版原子恢复") + renameatx_np.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + renameatx_np.restype = ctypes.c_int + at_fdcwd = -2 + rename_swap = 0x00000002 + if renameatx_np(at_fdcwd, os.fsencode(first), at_fdcwd, os.fsencode(second), rename_swap) != 0: + error_number = ctypes.get_errno() + raise MacOSDBKeyCaptureFailure( + "atomic_restore_failed", + f"无法原子交换微信原版与临时版本: {os.strerror(error_number)}", + wechat_modified=True, + ) + + +def _parse_passphrase(output: str) -> str: + values: list[str] = [] + for line in str(output or "").splitlines(): + match = _MEMORY_LINE_RE.match(line) + if match: + values.extend(re.findall(r"0x([0-9a-fA-F]{2})", match.group(1))) + candidate = "".join(values[:32]).lower() + return candidate if _PASSPHRASE_RE.fullmatch(candidate) else "" + + +def _build_lldb_capture_command(script_path: Path, timeout: int) -> str: + """Keep LLDB stdin open, but stop the keeper as soon as LLDB exits. + + The upstream wcdb-key-tool reads LLDB stdout continuously and terminates + the pipeline immediately after parsing 32 bytes. AppleScript's + ``do shell script`` only returns stdout after the privileged command exits, + so a plain ``(cat; sleep) | lldb`` makes a successful capture appear stuck + until the sleep finishes. This wrapper preserves the upstream stdin + behaviour while explicitly killing the producer when LLDB detaches. + """ + + script_arg = shlex.quote(str(script_path)) + keepalive = max(1, int(timeout)) + shell = ( + "capture_dir=$(/usr/bin/mktemp -d /tmp/wedata-lldb.XXXXXX)\n" + 'capture_fifo="$capture_dir/stdin"\n' + 'producer_pid=""\n' + 'lldb_pid=""\n' + 'watchdog_pid=""\n' + "cleanup() {\n" + ' if [ -n "$producer_pid" ]; then /bin/kill "$producer_pid" 2>/dev/null || true; fi\n' + ' if [ -n "$watchdog_pid" ]; then /bin/kill "$watchdog_pid" 2>/dev/null || true; fi\n' + ' if [ -n "$lldb_pid" ]; then /bin/kill "$lldb_pid" 2>/dev/null || true; fi\n' + ' /bin/rm -rf "$capture_dir"\n' + "}\n" + "trap cleanup EXIT HUP INT TERM\n" + '/usr/bin/mkfifo "$capture_fifo"\n' + # ``exec`` makes the recorded producer PID become the sleep process + # after cat finishes, so SIGTERM actually ends the keepalive instead + # of leaving a child sleep behind while the shell waits for it. + f"( /bin/cat {script_arg}; exec /bin/sleep {keepalive} ) > \"$capture_fifo\" &\n" + "producer_pid=$!\n" + '/usr/bin/env TERM=dumb /usr/bin/lldb < "$capture_fifo" 2>&1 &\n' + "lldb_pid=$!\n" + f'( /bin/sleep {keepalive}; /bin/kill -TERM "$lldb_pid" 2>/dev/null || true ) &\n' + "watchdog_pid=$!\n" + 'wait "$lldb_pid"\n' + "lldb_status=$?\n" + 'lldb_pid=""\n' + '/bin/kill "$watchdog_pid" 2>/dev/null || true\n' + 'wait "$watchdog_pid" 2>/dev/null || true\n' + 'watchdog_pid=""\n' + '/bin/kill "$producer_pid" 2>/dev/null || true\n' + 'wait "$producer_pid" 2>/dev/null || true\n' + 'producer_pid=""\n' + 'echo "WEDATA_LLDB_EXIT=$lldb_status"\n' + # Administrator approval is the only AppleScript-level failure. LLDB + # diagnostics stay in stdout so the caller can report the real cause. + "exit 0\n" + ) + return shlex.join(["/bin/bash", "-c", shell]) + + +def capture_passphrase_lldb(timeout: int = 240, *, pid: int | None = None) -> str: + if shutil.which("lldb") is None: + raise MacOSDBKeyCaptureFailure("lldb_missing", "未安装 LLDB,请先运行 xcode-select --install") + pid = pid or _find_wechat_pid() + if not pid: + raise MacOSDBKeyCaptureFailure("wechat_not_running", "未找到微信进程,请先启动微信") + + is_arm = platform.machine().lower() in {"arm64", "aarch64"} + password_register, length_register = ("x1", "x2") if is_arm else ("rsi", "rdx") + script = ( + "settings set target.preload-symbols false\n" + f"process attach -p {pid}\n" + f"breakpoint set -n CCKeyDerivationPBKDF -c '${length_register} == 32'\n" + "breakpoint command add 1\n" + f"memory read --size 1 --count 32 --format x ${password_register}\n" + "detach\n" + "quit\n" + "DONE\n" + "process continue\n" + ) + with tempfile.TemporaryDirectory(prefix="wedata-wxcap-") as temp_dir: + script_path = Path(temp_dir) / "capture.lldb" + script_path.write_text(script, encoding="utf-8") + os.chmod(script_path, 0o600) + command = _build_lldb_capture_command(script_path, timeout) + output = _run_as_administrator(command, timeout=float(timeout + 45)) + + passphrase = _parse_passphrase(output) + if passphrase: + return passphrase + compact = " ".join(output.split()) + if "attach failed" in compact.lower() or "not allowed to attach" in compact.lower(): + raise MacOSDBKeyCaptureFailure( + "lldb_attach_failed", + "LLDB 无法附加微信调试副本,请确认管理员授权已完成", + ) + raise MacOSDBKeyCaptureFailure( + "passphrase_not_captured", + "未捕获到 passphrase。请在捕获期间于微信中退出账号并重新登录后再试。", + process_attached=True, + ) + + +def save_passphrase(passphrase: str, *, home: Path | None = None) -> Path: + normalized = str(passphrase or "").strip().lower() + if not _PASSPHRASE_RE.fullmatch(normalized): + raise MacOSDBKeyCaptureFailure("invalid_passphrase", "捕获到的 passphrase 格式无效") + target = (home or Path.home()) / PASSPHRASE_RELATIVE_PATH + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + temp_path = target.with_suffix(".tmp") + temp_path.write_text(json.dumps({"passphrase": normalized}, indent=2), encoding="utf-8") + os.chmod(temp_path, 0o600) + temp_path.replace(target) + os.chmod(target, 0o600) + return target + + +def _validate_captured_passphrase(passphrase: str, probe_db_path: str | Path | None) -> None: + if probe_db_path is None: + return + database = Path(probe_db_path).expanduser() + try: + with database.open("rb") as handle: + page1 = handle.read(4096) + from .wechat_decrypt import PAGE_SIZE, _resolve_page1_key_material + + validated = ( + len(page1) >= PAGE_SIZE + and _resolve_page1_key_material(bytes.fromhex(passphrase), page1) is not None + ) + except (OSError, ValueError) as exc: + raise MacOSDBKeyCaptureFailure( + "passphrase_validation_failed", + f"无法使用目标数据库校验捕获结果: {database}", + process_attached=True, + ) from exc + if not validated: + raise MacOSDBKeyCaptureFailure( + "passphrase_database_mismatch", + "已捕获登录时的 passphrase,但无法通过所选数据库校验。请确认调试微信登录的是同一账号,且使用默认微信数据路径后重试。", + process_attached=True, + ) + + +def prepare_macos_passphrase_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, +) -> dict[str, Any]: + """Temporarily re-sign the default-path client and launch without LLDB.""" + + from .macos_inplace_capture import prepare_in_place_capture + + return prepare_in_place_capture(wechat_install_path, backup_root=backup_root) + + +def cleanup_macos_passphrase_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, +) -> dict[str, Any]: + """Close the temporary client and restore the Tencent-signed application.""" + + from .macos_inplace_capture import cleanup_in_place_capture + + return cleanup_in_place_capture(wechat_install_path, backup_root=backup_root) + + +def capture_prepared_macos_passphrase( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + probe_db_path: str | Path | None = None, + timeout: int = 240, +) -> dict[str, Any]: + """Attach after logout, capture on re-login, then restore the official app.""" + + from .macos_inplace_capture import capture_prepared_in_place + + return capture_prepared_in_place( + wechat_install_path, + backup_root=backup_root, + probe_db_path=probe_db_path, + timeout=timeout, + ) + + +def preflight_prepared_macos_passphrase( + wechat_install_path: str | Path | None, + *, + backup_root: Path, +) -> dict[str, Any]: + """Validate capture breakpoints and detach before the user logs out.""" + + from .macos_inplace_capture import preflight_prepared_in_place_capture + + return preflight_prepared_in_place_capture(wechat_install_path, backup_root=backup_root) + + +def capture_and_cache_macos_passphrase( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + probe_db_path: str | Path | None = None, + timeout: int = 240, +) -> dict[str, Any]: + prepare_macos_passphrase_capture(wechat_install_path, backup_root=backup_root) + preflight_prepared_macos_passphrase(wechat_install_path, backup_root=backup_root) + return capture_prepared_macos_passphrase( + wechat_install_path, + backup_root=backup_root, + probe_db_path=probe_db_path, + timeout=timeout, + ) + + +__all__ = [ + "MacOSDBKeyCaptureFailure", + "capture_and_cache_macos_passphrase", + "capture_passphrase_lldb", + "capture_prepared_macos_passphrase", + "cleanup_macos_passphrase_capture", + "ensure_wechat_debuggable", + "ensure_wechat_in_place_debuggable", + "inspect_wechat_signature", + "normalize_wechat_app_path", + "preflight_prepared_macos_passphrase", + "prepare_macos_passphrase_capture", + "restore_official_wechat_if_needed", + "save_passphrase", + "verify_original_wechat_backup", +] diff --git a/src/wechat_decrypt_tool/macos_db_key_discovery.py b/src/wechat_decrypt_tool/macos_db_key_discovery.py new file mode 100644 index 00000000..6488f364 --- /dev/null +++ b/src/wechat_decrypt_tool/macos_db_key_discovery.py @@ -0,0 +1,191 @@ +"""Safe macOS database-key discovery without attaching to or modifying WeChat.""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_HEX_KEY_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_KEY_FIELD_NAMES = { + "passphrase", + "db_key", + "database_key", + "raw_key", + "enc_key", + "default_key", + "__default_key", +} + + +@dataclass(frozen=True, slots=True) +class MacOSDBKeyDiscoveryFailure(RuntimeError): + code: str + message: str + checked_sources: int = 0 + + def __str__(self) -> str: + return self.message + + +def _normalize_hex_key(value: Any) -> str: + raw = str(value or "").strip() + if raw.lower().startswith("0x"): + raw = raw[2:] + if raw.lower().startswith("x'") and raw.endswith("'"): + raw = raw[2:-1] + return raw.lower() if _HEX_KEY_RE.fullmatch(raw) else "" + + +def _candidate_files(database: Path, home: Path) -> list[Path]: + candidates = [ + home / ".wcdb-key-tool" / "wechat-passphrase.json", + home / ".wechat-cli" / "all_keys.json", + home / ".wechat-cli" / "keys.json", + home / ".wechat-summary" / "all_keys.json", + ] + current = database.parent + for _ in range(4): + candidates.extend(current / name for name in ("all_keys.json", "wechat_keys.json", "keys.json")) + if current.parent == current: + break + current = current.parent + + unique: list[Path] = [] + seen: set[str] = set() + for path in candidates: + normalized = str(path.expanduser()) + if normalized in seen: + continue + seen.add(normalized) + unique.append(path.expanduser()) + return unique + + +def _path_matches_database(key: str, database: Path) -> bool: + normalized = str(key or "").replace("\\", "/").strip().lower() + if not normalized: + return False + db_path = database.as_posix().lower() + return ( + normalized == database.name.lower() + or db_path.endswith(normalized) + or normalized.endswith("/" + database.name.lower()) + ) + + +def _extract_candidates(payload: Any, database: Path) -> Iterable[tuple[str, str]]: + if not isinstance(payload, dict): + return + + for field, value in payload.items(): + field_name = str(field or "").strip().lower() + normalized = _normalize_hex_key(value) + if normalized and field_name in _KEY_FIELD_NAMES: + yield normalized, field_name + + maps: list[dict[str, Any]] = [payload] + for field in ("keys", "databases", "derived_key_map", "key_map"): + nested = payload.get(field) + if isinstance(nested, dict): + maps.append(nested) + + for mapping in maps: + for key, value in mapping.items(): + if not _path_matches_database(str(key), database): + continue + if isinstance(value, dict): + for nested_field in _KEY_FIELD_NAMES: + normalized = _normalize_hex_key(value.get(nested_field)) + if normalized: + yield normalized, f"path:{nested_field}" + else: + normalized = _normalize_hex_key(value) + if normalized: + yield normalized, "path" + + +def discover_macos_db_key( + probe_db_path: str | Path, + *, + home: str | Path | None = None, + extra_files: Iterable[str | Path] = (), +) -> dict[str, Any]: + if sys.platform != "darwin": + raise MacOSDBKeyDiscoveryFailure("unsupported_platform", "安全密钥发现仅支持 macOS") + + database = Path(probe_db_path).expanduser() + if not database.is_file(): + raise MacOSDBKeyDiscoveryFailure("database_missing", f"用于校验的数据库不存在: {database}") + try: + with database.open("rb") as handle: + page1 = handle.read(4096) + except PermissionError as exc: + raise MacOSDBKeyDiscoveryFailure( + "database_permission_denied", + "macOS 已拒绝读取微信数据库目录。请在“系统设置 → 隐私与安全性 → 完全磁盘访问权限”中启用当前工具,然后完全退出并重新打开。", + ) from exc + if page1.startswith(b"SQLite format 3"): + raise MacOSDBKeyDiscoveryFailure("database_plaintext", "所选数据库已经是明文 SQLite") + + from .wechat_decrypt import PAGE_SIZE, _resolve_page1_key_material + + if len(page1) < PAGE_SIZE: + raise MacOSDBKeyDiscoveryFailure("database_incomplete", "数据库首页不足 4096 字节") + + home_path = Path(home).expanduser() if home is not None else Path.home() + files = [*_candidate_files(database, home_path), *(Path(item).expanduser() for item in extra_files)] + checked = 0 + seen_keys: set[str] = set() + for source in files: + if not source.is_file(): + continue + checked += 1 + try: + payload = json.loads(source.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + continue + for candidate, field in _extract_candidates(payload, database): + if candidate in seen_keys: + continue + seen_keys.add(candidate) + resolved = _resolve_page1_key_material(bytes.fromhex(candidate), page1) + if resolved is None: + continue + _enc_key, _mac_key, mode = resolved + return { + "platform": "macos", + "db_key": candidate, + "method": "safe_local_cache", + "source": str(source), + "source_field": field, + "key_mode": mode, + "probe_db_path": str(database), + "validated": True, + "checked_sources": checked, + "wechat_modified": False, + "process_attached": False, + "database_key_extraction": True, + "manual_input_supported": True, + } + + raise MacOSDBKeyDiscoveryFailure( + "safe_key_not_found", + "未在本机已保存的密钥或 passphrase 缓存中找到能通过当前数据库校验的候选。" + "微信 4.1+ 的首次 passphrase 提取会先在所选备份目录验证腾讯原版备份,为默认路径微信" + "准备同卷 APFS 写时复制恢复副本,再临时启用调试签名。请只在系统自动打开的窗口" + "登录并进入聊天主界面;完成断点预检并分离后先退出账号,等微信显示登录界面再" + "启动监测,随后只重新登录同一个账号。" + "候选通过当前数据库校验后会缓存;完成、失败或取消时都会关闭临时版本、恢复腾讯" + "原签名并清理临时恢复副本。" + "也可导入另一台设备已生成的 all_keys.json / " + "wechat-passphrase.json,或手动填写已验证密钥。", + checked_sources=checked, + ) + + +__all__ = ["MacOSDBKeyDiscoveryFailure", "discover_macos_db_key"] diff --git a/src/wechat_decrypt_tool/macos_inplace_capture.py b/src/wechat_decrypt_tool/macos_inplace_capture.py new file mode 100644 index 00000000..30679c06 --- /dev/null +++ b/src/wechat_decrypt_tool/macos_inplace_capture.py @@ -0,0 +1,497 @@ +"""Recoverable in-place LLDB capture for macOS WeChat 4.1+. + +The upstream macOS method requires the installed WeChat bundle to be ad-hoc +signed so LLDB can attach. This module makes that temporary mutation +transactional: a verified Tencent archive is stored in the caller-selected +backup directory, recovery state is fsynced locally before signing, and the official app +is restored on every terminal path. +""" + +from __future__ import annotations + +import json +import os +import platform +import time +from pathlib import Path +from typing import Any + +from .macos_clone_capture import ( + _breakpoint_preflight_path, + _remove_breakpoint_preflight, + capture_salt_matched_passphrase, + preflight_capture_breakpoints, +) +from .macos_db_key_capture import ( + DEFAULT_DEBUG_ROOT, + DEFAULT_WECHAT_APP, + MacOSDBKeyCaptureFailure, + _find_wechat_main_pid, + _has_compatible_in_place_signature, + _is_tencent_official_signature, + _launch_wechat, + _validate_captured_passphrase, + ensure_wechat_in_place_debuggable, + inspect_wechat_signature, + normalize_wechat_app_path, + restore_official_wechat_if_needed, + save_passphrase, +) + +IN_PLACE_STATE_NAME = "prepared-in-place-capture.json" +STATE_SCHEMA_VERSION = 1 + + +def _state_path(debug_root: Path) -> Path: + return debug_root.expanduser() / IN_PLACE_STATE_NAME + + +def has_pending_in_place_capture(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> bool: + return _state_path(debug_root).is_file() + + +def _write_state(debug_root: Path, payload: dict[str, Any]) -> Path: + target = _state_path(debug_root) + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") + encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(temporary, flags, 0o600) + try: + view = memoryview(encoded) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + temporary.replace(target) + os.chmod(target, 0o600) + try: + directory_fd = os.open(target.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + # Some network-backed home directories reject directory fsync. The + # file itself is already fsynced and atomically renamed. + pass + return target + + +def _read_state(debug_root: Path) -> dict[str, Any]: + target = _state_path(debug_root) + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as exc: + raise MacOSDBKeyCaptureFailure( + "in_place_capture_not_prepared", + "没有找到可恢复的临时重签状态,请重新开始。", + ) from exc + if not isinstance(payload, dict) or int(payload.get("schema_version") or 0) != STATE_SCHEMA_VERSION: + raise MacOSDBKeyCaptureFailure("in_place_capture_state_invalid", "临时重签恢复状态无效,已停止操作") + return payload + + +def _remove_state(debug_root: Path) -> None: + try: + _state_path(debug_root).unlink() + except FileNotFoundError: + pass + + +def _safe_backup_from_state(state: dict[str, Any], backup_root: Path) -> Path: + candidate = Path(str(state.get("backup_path") or "")).expanduser() + root = Path(os.path.abspath(os.fspath(backup_root.expanduser()))) + normalized = Path(os.path.abspath(os.fspath(candidate))) + if normalized.parent != root or normalized.suffix.lower() != ".zip" or not normalized.name.startswith("WeChat-"): + raise MacOSDBKeyCaptureFailure( + "official_backup_path_unsafe", + "恢复状态中的备份路径不在当前所选备份目录,已拒绝覆盖微信。", + wechat_modified=True, + ) + # Do not require the selected backup volume to be reachable here. A verified same-volume APFS + # clone is created before signing and is the first recovery source. The + # archive remains the durable second source if that clone is missing. + return normalized + + +def _validate_state_target(state: dict[str, Any], wechat_app: Path) -> None: + if Path(str(state.get("wechat_app_path") or "")) != wechat_app: + raise MacOSDBKeyCaptureFailure( + "in_place_capture_target_mismatch", + "恢复状态对应的微信安装路径与当前路径不一致,已拒绝覆盖。", + wechat_modified=True, + ) + + +def _prepared_signature_is_valid(wechat_app: Path) -> bool: + signature = inspect_wechat_signature(wechat_app) + return bool( + signature.get("valid") + and signature.get("ad_hoc") + and not signature.get("hardened_runtime") + and _has_compatible_in_place_signature(wechat_app) + ) + + +def recover_stale_in_place_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + """Restore a Tencent-signed bundle from a previously recorded transaction.""" + + wechat_app = normalize_wechat_app_path(wechat_install_path) + if not has_pending_in_place_capture(debug_root=debug_root): + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "in_place_recovery_state_missing", + "微信当前不是腾讯原签名,且没有可信恢复状态;为避免覆盖错误应用,已停止自动恢复。", + wechat_modified=True, + ) + return { + "official_wechat_verified": True, + "official_wechat_restored": False, + "wechat_modified": False, + } + + state = _read_state(debug_root) + _validate_state_target(state, wechat_app) + backup_path = _safe_backup_from_state(state, backup_root) + expected_version = (str(state.get("version") or ""), str(state.get("build") or "")) + expected_cdhash = str(state.get("official_cdhash") or "").lower() + result = restore_official_wechat_if_needed( + wechat_app, + backup_path, + expected_version=expected_version, + expected_cdhash=expected_cdhash or None, + ) + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "official_restore_verify_failed", + "自动恢复完成后仍无法验证腾讯原版微信签名,恢复状态已保留。", + wechat_modified=True, + ) + _remove_breakpoint_preflight(debug_root) + # Remove durable state last. If the process dies after the atomic app + # exchange but before this unlink, the next startup observes an official + # installation, removes any displaced staging bundle, and finishes safely. + _remove_state(debug_root) + return { + **result, + "wechat_modified": False, + "backup_path": str(backup_path), + } + + +def _restore_after_terminal_path( + wechat_app: Path, + *, + backup_root: Path, + debug_root: Path, + original_error: Exception | None = None, +) -> dict[str, Any]: + try: + return recover_stale_in_place_capture( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + ) + except Exception as restore_error: + if original_error is None: + raise + raise MacOSDBKeyCaptureFailure( + "official_restore_failed", + f"捕获未完成,且自动恢复腾讯原版微信失败: {restore_error}", + requires_wechat_resign=True, + wechat_modified=True, + ) from restore_error + + +def prepare_in_place_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + """Verify backup, persist recovery state, re-sign in place, and launch.""" + + if platform.system().lower() != "darwin": + raise MacOSDBKeyCaptureFailure("unsupported_platform", "LLDB 密钥捕获仅支持 macOS") + wechat_app = normalize_wechat_app_path(wechat_install_path) + if wechat_app != DEFAULT_WECHAT_APP: + raise MacOSDBKeyCaptureFailure( + "in_place_default_path_required", + "临时重签仅允许作用于 /Applications/WeChat.app,请先使用微信默认安装路径。", + ) + + # A killed prior run is recovered before a new mutation can begin. + if has_pending_in_place_capture(debug_root=debug_root): + recover_stale_in_place_capture(wechat_app, backup_root=backup_root, debug_root=debug_root) + else: + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "in_place_recovery_state_missing", + "微信当前不是腾讯原签名,且没有可信恢复状态;已停止临时重签。", + wechat_modified=True, + ) + debug_root = debug_root.expanduser() + debug_root.mkdir(parents=True, exist_ok=True) + os.chmod(debug_root, 0o700) + + def record_recovery_state(recovery: dict[str, Any]) -> None: + _write_state( + debug_root, + { + "schema_version": STATE_SCHEMA_VERSION, + "stage": "backup_verified", + "created_at": int(time.time()), + **recovery, + }, + ) + + try: + prepared = ensure_wechat_in_place_debuggable( + wechat_app, + backup_root, + before_resign=record_recovery_state, + ) + state = _read_state(debug_root) + state["stage"] = "resigned" + _write_state(debug_root, state) + debug_pid = _launch_wechat(wechat_app) + state["stage"] = "launched" + state["debug_pid"] = debug_pid + _write_state(debug_root, state) + except Exception as exc: + if has_pending_in_place_capture(debug_root=debug_root): + _restore_after_terminal_path( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + original_error=exc, + ) + raise + + return { + "method": "macos_inplace_lldb_prepare", + **prepared, + "debug_pid": debug_pid, + "state_path": str(_state_path(debug_root)), + "process_attached": False, + "ready_for_preflight": True, + "normal_wechat_running": False, + } + + +def cleanup_in_place_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + wechat_app = normalize_wechat_app_path(wechat_install_path) + if has_pending_in_place_capture(debug_root=debug_root): + recovery = recover_stale_in_place_capture( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + ) + else: + signature = inspect_wechat_signature(wechat_app) + if not _is_tencent_official_signature(signature): + raise MacOSDBKeyCaptureFailure( + "in_place_recovery_state_missing", + "微信当前不是腾讯原签名,且没有可信恢复状态;已停止自动恢复。", + wechat_modified=True, + ) + recovery = { + "official_wechat_verified": True, + "official_wechat_restored": False, + "wechat_modified": False, + } + return { + "method": "macos_inplace_lldb_cancelled", + "wechat_resigned": False, + "official_wechat_preserved": True, + "normal_wechat_running": False, + **recovery, + } + + +def _require_prepared_process( + wechat_app: Path, + *, + backup_root: Path, + debug_root: Path, +) -> tuple[dict[str, Any], int]: + state = _read_state(debug_root) + _validate_state_target(state, wechat_app) + _safe_backup_from_state(state, backup_root) + if not _prepared_signature_is_valid(wechat_app): + raise MacOSDBKeyCaptureFailure( + "in_place_signature_changed", + "临时调试微信签名状态已变化,将先恢复腾讯原版后再重新开始。", + wechat_modified=True, + ) + saved_pid = int(state.get("debug_pid") or 0) + current_pid = _find_wechat_main_pid(wechat_app) + if saved_pid <= 0 or current_pid is None or saved_pid != current_pid: + raise MacOSDBKeyCaptureFailure( + "debug_wechat_not_running", + "临时调试微信已经退出,将自动恢复腾讯原版微信。", + wechat_modified=True, + ) + return state, current_pid + + +def preflight_prepared_in_place_capture( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + wechat_app = normalize_wechat_app_path(wechat_install_path) + try: + state, debug_pid = _require_prepared_process( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + ) + result = preflight_capture_breakpoints(pid=debug_pid, debug_root=debug_root) + state["stage"] = "preflight_passed" + _write_state(debug_root, state) + except Exception as exc: + if has_pending_in_place_capture(debug_root=debug_root): + _restore_after_terminal_path( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + original_error=exc, + ) + raise + result.update( + { + "method": "macos_inplace_lldb_preflight", + "debug_app_path": str(wechat_app), + "official_wechat_preserved": False, + "wechat_modified": True, + "wechat_resigned": True, + } + ) + return result + + +def capture_prepared_in_place( + wechat_install_path: str | Path | None, + *, + backup_root: Path, + probe_db_path: str | Path | None, + timeout: int = 240, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + wechat_app = normalize_wechat_app_path(wechat_install_path) + cache_path: Path | None = None + state: dict[str, Any] = {} + recovery: dict[str, Any] = {} + try: + state, debug_pid = _require_prepared_process( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + ) + if not probe_db_path: + raise MacOSDBKeyCaptureFailure("probe_database_required", "必须提供目标数据库用于校验捕获结果") + probe_database = Path(probe_db_path).expanduser() + try: + with probe_database.open("rb") as handle: + probe_page1 = handle.read(4096) + except OSError as exc: + raise MacOSDBKeyCaptureFailure( + "probe_database_unreadable", + f"无法读取目标数据库用于实时校验: {probe_database}", + ) from exc + if len(probe_page1) < 4096 or probe_page1.startswith(b"SQLite format 3"): + raise MacOSDBKeyCaptureFailure("probe_database_invalid", f"目标数据库不是有效的加密 WCDB: {probe_database}") + + preflight_path = _breakpoint_preflight_path(debug_root) + try: + preflight = json.loads(preflight_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError) as exc: + raise MacOSDBKeyCaptureFailure( + "capture_preflight_required", + "尚未完成断点预检;监测未启动,将恢复腾讯原版微信。", + ) from exc + if int(preflight.get("pid") or 0) != debug_pid or ( + int(preflight.get("pbkdf_locations") or 0) <= 0 + and int(preflight.get("key_return_locations") or 0) <= 0 + ): + raise MacOSDBKeyCaptureFailure( + "capture_preflight_stale", + "断点预检结果与当前微信进程不匹配,将恢复腾讯原版微信。", + ) + + passphrase = capture_salt_matched_passphrase( + pid=debug_pid, + expected_salts=[probe_page1[:16].hex()], + probe_db_path=probe_database, + timeout=timeout, + enable_key_return_fallback=int(preflight.get("key_return_locations") or 0) > 0, + ) + _validate_captured_passphrase(passphrase, probe_database) + cache_path = save_passphrase(passphrase) + except Exception as exc: + if has_pending_in_place_capture(debug_root=debug_root): + _restore_after_terminal_path( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + original_error=exc, + ) + raise + else: + recovery = _restore_after_terminal_path( + wechat_app, + backup_root=backup_root, + debug_root=debug_root, + ) + + if cache_path is None: + raise MacOSDBKeyCaptureFailure("passphrase_not_saved", "passphrase 未能安全保存") + return { + "method": "macos_inplace_lldb_passphrase", + "db_key": passphrase, + "cache_path": str(cache_path), + "wechat_modified": False, + "wechat_resigned": True, + "official_wechat_preserved": True, + "official_wechat_verified": bool(recovery.get("official_wechat_verified")), + "official_wechat_restored": bool(recovery.get("official_wechat_restored")), + "debug_app_path": str(wechat_app), + "debug_copy_created": False, + "profile_cloned": False, + "process_attached": True, + "normal_wechat_running": False, + "backup_path": str(state.get("backup_path") or ""), + "backup_created": bool(state.get("backup_created")), + } + + +__all__ = [ + "capture_prepared_in_place", + "cleanup_in_place_capture", + "has_pending_in_place_capture", + "preflight_prepared_in_place_capture", + "prepare_in_place_capture", + "recover_stale_in_place_capture", +] diff --git a/tests/test_macos_clone_capture.py b/tests/test_macos_clone_capture.py new file mode 100644 index 00000000..4f756508 --- /dev/null +++ b/tests/test_macos_clone_capture.py @@ -0,0 +1,356 @@ +import ast +import ctypes +import errno +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool.macos_clone_capture import ( + _clone_path_force, + _materialize_private_xwechat_files, + _normalize_salts, + _remove_clone_profile, + _require_local_apfs_clone, + build_lldb_breakpoint_preflight_script, + build_lldb_salt_capture_script, + capture_prepared_clone, + preflight_capture_breakpoints, + preflight_prepared_clone, +) +from wechat_decrypt_tool.macos_db_key_capture import MacOSDBKeyCaptureFailure + + +class TestMacOSCloneCapture(unittest.TestCase): + def test_clone_profile_cleanup_retries_transient_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + debug_root = Path(temporary_dir) + profile = debug_root / "profile-clone-test" + profile.mkdir() + with ( + patch( + "wechat_decrypt_tool.macos_clone_capture.shutil.rmtree", + side_effect=[OSError("busy"), None], + ) as remove, + patch("wechat_decrypt_tool.macos_clone_capture.time.sleep") as wait, + ): + _remove_clone_profile(profile, debug_root) + + self.assertEqual(remove.call_count, 2) + wait.assert_called_once_with(0.4) + + def test_clone_permission_error_requests_full_disk_access(self) -> None: + class DeniedCloneFile: + argtypes = None + restype = None + + def __call__(self, _source, _destination, _flags): + ctypes.set_errno(errno.EPERM) + return -1 + + class FakeLibC: + clonefile = DeniedCloneFile() + + with ( + patch("wechat_decrypt_tool.macos_clone_capture._require_local_apfs_clone"), + patch("wechat_decrypt_tool.macos_clone_capture.ctypes.CDLL", return_value=FakeLibC()), + patch("wechat_decrypt_tool.macos_clone_capture.os.path.lexists", return_value=False), + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + _clone_path_force( + Path.home() / "Library/Containers/com.tencent.xinWeChat", + Path("/private/clone"), + ) + + self.assertEqual(context.exception.code, "database_permission_denied") + self.assertIn("完全磁盘访问权限", str(context.exception)) + self.assertIn("重启", str(context.exception)) + + def test_clone_permission_error_outside_wechat_container_keeps_generic_failure(self) -> None: + class DeniedCloneFile: + argtypes = None + restype = None + + def __call__(self, _source, _destination, _flags): + ctypes.set_errno(errno.EPERM) + return -1 + + class FakeLibC: + clonefile = DeniedCloneFile() + + with ( + patch("wechat_decrypt_tool.macos_clone_capture._require_local_apfs_clone"), + patch("wechat_decrypt_tool.macos_clone_capture.ctypes.CDLL", return_value=FakeLibC()), + patch("wechat_decrypt_tool.macos_clone_capture.os.path.lexists", return_value=False), + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + _clone_path_force(Path("/Applications/WeChat.app"), Path("/private/clone")) + + self.assertEqual(context.exception.code, "clonefile_failed") + + def test_clone_source_stat_permission_error_requests_full_disk_access(self) -> None: + source = Path.home() / "Library/Containers/com.tencent.xinWeChat" + + with patch.object(Path, "stat", side_effect=PermissionError(errno.EPERM, "Operation not permitted")): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + _require_local_apfs_clone(source, Path("/private")) + + self.assertEqual(context.exception.code, "database_permission_denied") + + def test_breakpoint_preflight_requires_a_loaded_executable_address(self) -> None: + script = build_lldb_breakpoint_preflight_script(Path("/tmp/preflight.json")) + + ast.parse(script) + self.assertIn('BreakpointCreateByName("CCKeyDerivationPBKDF")', script) + self.assertIn("ResolveFileAddress(offset)", script) + self.assertIn("lldb.ePermissionsExecutable", script) + self.assertIn("lldb.LLDB_INVALID_ADDRESS", script) + self.assertIn("process.Detach()", script) + self.assertIn("WEDATA_BREAKPOINT_PREFLIGHT", script) + + def test_system_pbkdf_capture_can_disable_internal_return_fallback(self) -> None: + script = build_lldb_salt_capture_script( + Path("/tmp/result.json"), + ["12" * 16], + probe_page1=b"x" * 4096, + enable_key_return_fallback=False, + ) + + ast.parse(script) + self.assertIn("ENABLE_KEY_RETURN_FALLBACK = False", script) + self.assertIn("if ENABLE_KEY_RETURN_FALLBACK:", script) + self.assertIn('BreakpointCreateByName("CCKeyDerivationPBKDF")', script) + + def test_prepared_capture_arms_resolved_internal_return_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + debug_root = Path(temporary_dir) + debug_app = debug_root / "WeChat-Debug.app" + profile = debug_root / "profile-clone-test" + (debug_root / "breakpoint-preflight.json").write_text( + json.dumps({"pid": 321, "pbkdf_locations": 1, "key_return_locations": 1}), + encoding="utf-8", + ) + with ( + patch("wechat_decrypt_tool.macos_clone_capture.normalize_wechat_app_path", return_value=Path("/Applications/WeChat.app")), + patch("wechat_decrypt_tool.macos_clone_capture.inspect_wechat_signature", return_value={}), + patch("wechat_decrypt_tool.macos_clone_capture._is_tencent_official_signature", return_value=True), + patch( + "wechat_decrypt_tool.macos_clone_capture._read_state", + return_value={ + "debug_app_path": str(debug_app), + "profile_path": str(profile), + "debug_pid": 321, + "database_salts": ["12" * 16], + }, + ), + patch("wechat_decrypt_tool.macos_clone_capture._is_safe_clone_profile", return_value=True), + patch("wechat_decrypt_tool.macos_clone_capture._debug_copy_is_ready", return_value=True), + patch("wechat_decrypt_tool.macos_clone_capture._find_wechat_main_pid", return_value=321), + patch( + "wechat_decrypt_tool.macos_clone_capture.capture_salt_matched_passphrase", + return_value="ab" * 32, + ) as capture, + patch("wechat_decrypt_tool.macos_clone_capture._validate_captured_passphrase"), + patch( + "wechat_decrypt_tool.macos_clone_capture.save_passphrase", + return_value=debug_root / "key.json", + ), + patch("wechat_decrypt_tool.macos_clone_capture._cleanup_prepared_clone"), + ): + capture_prepared_clone( + "/Applications/WeChat.app", + backup_root=debug_root / "backups", + probe_db_path=debug_root / "message_0.db", + debug_root=debug_root, + ) + + self.assertTrue(capture.call_args.kwargs["enable_key_return_fallback"]) + + def test_breakpoint_preflight_persists_only_non_secret_readiness_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + debug_root = Path(temporary_dir) + + def write_preflight(_command, *, timeout): + self.assertEqual(timeout, 90) + (debug_root / "breakpoint-preflight.json").write_text( + json.dumps( + { + "pid": 321, + "pbkdf_locations": 2, + "key_return_locations": 1, + "matched_modules": [{"module": "wechat.dylib"}], + "rejected_points": [], + } + ), + encoding="utf-8", + ) + return "WEDATA_BREAKPOINT_PREFLIGHT 2 1" + + with ( + patch("wechat_decrypt_tool.macos_clone_capture.platform.machine", return_value="arm64"), + patch("wechat_decrypt_tool.macos_clone_capture.shutil.which", return_value="/usr/bin/lldb"), + patch( + "wechat_decrypt_tool.macos_clone_capture._run_as_administrator", + side_effect=write_preflight, + ), + ): + result = preflight_capture_breakpoints(pid=321, debug_root=debug_root) + + self.assertEqual(result["pbkdf_locations"], 2) + self.assertEqual(result["key_return_locations"], 1) + self.assertTrue(result["ready_for_monitoring"]) + self.assertTrue(result["process_detached"]) + saved = json.loads((debug_root / "breakpoint-preflight.json").read_text(encoding="utf-8")) + self.assertNotIn("passphrase", saved) + self.assertNotIn("key", saved) + + def test_breakpoint_preflight_rejects_zero_resolved_locations(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + debug_root = Path(temporary_dir) + + def write_preflight(_command, *, timeout): + (debug_root / "breakpoint-preflight.json").write_text( + json.dumps({"pid": 321, "pbkdf_locations": 0, "key_return_locations": 0}), + encoding="utf-8", + ) + return "WEDATA_BREAKPOINT_PREFLIGHT 0 0" + + with ( + patch("wechat_decrypt_tool.macos_clone_capture.platform.machine", return_value="arm64"), + patch("wechat_decrypt_tool.macos_clone_capture.shutil.which", return_value="/usr/bin/lldb"), + patch( + "wechat_decrypt_tool.macos_clone_capture._run_as_administrator", + side_effect=write_preflight, + ), + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + preflight_capture_breakpoints(pid=321, debug_root=debug_root) + + self.assertEqual(context.exception.code, "capture_breakpoints_unavailable") + self.assertFalse((debug_root / "breakpoint-preflight.json").exists()) + + def test_prepared_preflight_cleans_private_clone_after_failure(self) -> None: + debug_root = Path("/tmp/wcda-test-debug") + debug_app = debug_root / "WeChat-Debug.app" + profile = debug_root / "profile-clone-test" + failure = MacOSDBKeyCaptureFailure("capture_breakpoints_unavailable", "no breakpoints") + with ( + patch("wechat_decrypt_tool.macos_clone_capture.normalize_wechat_app_path", return_value=Path("/Applications/WeChat.app")), + patch("wechat_decrypt_tool.macos_clone_capture.inspect_wechat_signature", return_value={}), + patch("wechat_decrypt_tool.macos_clone_capture._is_tencent_official_signature", return_value=True), + patch( + "wechat_decrypt_tool.macos_clone_capture._read_state", + return_value={"debug_app_path": str(debug_app), "profile_path": str(profile), "debug_pid": 321}, + ), + patch("wechat_decrypt_tool.macos_clone_capture._is_safe_clone_profile", return_value=True), + patch("wechat_decrypt_tool.macos_clone_capture._debug_copy_is_ready", return_value=True), + patch("wechat_decrypt_tool.macos_clone_capture._find_wechat_main_pid", return_value=321), + patch("wechat_decrypt_tool.macos_clone_capture.preflight_capture_breakpoints", side_effect=failure), + patch("wechat_decrypt_tool.macos_clone_capture._cleanup_prepared_clone") as cleanup, + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + preflight_prepared_clone( + "/Applications/WeChat.app", + backup_root=Path("/tmp/backups"), + debug_root=debug_root, + ) + + self.assertEqual(context.exception.code, "capture_breakpoints_unavailable") + cleanup.assert_called_once_with(debug_root) + + def test_lldb_callback_requires_database_specific_pbkdf2_call(self) -> None: + salt = "12" * 16 + script = build_lldb_salt_capture_script( + Path("/tmp/result.json"), + [salt], + probe_page1=b"x" * 4096, + ) + + ast.parse(script) + self.assertIn("algorithm != 2", script) + self.assertIn("password_len != 32", script) + self.assertIn("salt_len != 16", script) + self.assertIn("prf != 5", script) + self.assertIn("rounds != 256000", script) + self.assertIn(salt, script) + self.assertIn("salt.hex() not in EXPECTED_SALTS", script) + self.assertIn("os.fsync", script) + self.assertNotIn('print(password.hex()', script) + self.assertLess(script.index("salt = process.ReadMemory"), script.index("password = process.ReadMemory")) + self.assertIn("0xB8", script) + self.assertIn("wechat_key_return", script) + self.assertIn("_candidate_matches_page1", script) + self.assertIn("WEDATA_MATCHED_VALIDATED_DATABASE_KEY", script) + self.assertIn("os._exit(24)", script) + self.assertIn("lldb.ePermissionsExecutable", script) + self.assertIn("lldb.LLDB_INVALID_ADDRESS", script) + + def test_salt_normalization_rejects_non_database_values(self) -> None: + self.assertEqual(_normalize_salts(["AB" * 16, bytes.fromhex("cd" * 16), "bad"]), ["ab" * 16, "cd" * 16]) + + @unittest.skipUnless(sys.platform == "darwin", "APFS clonefile is macOS-specific") + def test_private_snapshot_replaces_external_xwechat_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + source_documents = root / "source/Documents" + local_source = source_documents / "app_data/xwechat_files" + local_source.mkdir(parents=True) + (local_source / "marker").write_text("private-copy", encoding="utf-8") + external = root / "external" + external.mkdir() + cloned_documents = root / "clone/Documents" + cloned_documents.mkdir(parents=True) + os.symlink(external, cloned_documents / "xwechat_files") + + _materialize_private_xwechat_files(source_documents, cloned_documents) + + result = cloned_documents / "xwechat_files" + self.assertTrue(result.is_dir()) + self.assertFalse(result.is_symlink()) + self.assertEqual((result / "marker").read_text(encoding="utf-8"), "private-copy") + self.assertFalse((external / "marker").exists()) + + @unittest.skipUnless(sys.platform == "darwin", "APFS clonefile is macOS-specific") + def test_private_snapshot_refuses_xwechat_source_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + source_documents = root / "source/Documents" + (source_documents / "app_data").mkdir(parents=True) + external = root / "mounted-nas" + external.mkdir() + (external / "marker").write_text("must-not-copy", encoding="utf-8") + os.symlink(external, source_documents / "app_data/xwechat_files") + cloned_documents = root / "clone/Documents" + cloned_documents.mkdir(parents=True) + + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + _materialize_private_xwechat_files(source_documents, cloned_documents) + + self.assertEqual(context.exception.code, "wechat_data_snapshot_source_missing") + self.assertFalse((cloned_documents / "xwechat_files").exists()) + + @unittest.skipUnless(sys.platform == "darwin", "APFS clonefile is macOS-specific") + def test_force_clone_preserves_nested_symlink_without_following_it(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + source = root / "source" + source.mkdir() + (source / "marker").write_text("private", encoding="utf-8") + os.symlink("/Volumes/BackupVolume/xwechat_files", source / "external-link") + destination = root / "destination" + + _clone_path_force(source, destination) + + self.assertEqual((destination / "marker").read_text(encoding="utf-8"), "private") + self.assertTrue((destination / "external-link").is_symlink()) + self.assertEqual(os.readlink(destination / "external-link"), "/Volumes/BackupVolume/xwechat_files") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_macos_db_key_capture.py b/tests/test_macos_db_key_capture.py new file mode 100644 index 00000000..b54e4f44 --- /dev/null +++ b/tests/test_macos_db_key_capture.py @@ -0,0 +1,336 @@ +import json +import os +import plistlib +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool.macos_db_key_capture import ( + MacOSDBKeyCaptureFailure, + _atomic_swap_paths, + _build_lldb_capture_command, + _has_compatible_debug_entitlements, + _has_compatible_in_place_signature, + _has_debug_copy_marker, + _mark_debug_copy, + _parse_passphrase, + _quit_wechat, + backup_original_wechat, + capture_and_cache_macos_passphrase, + cleanup_macos_passphrase_capture, + ensure_wechat_debuggable, + ensure_wechat_in_place_debuggable, + restore_official_wechat_if_needed, + save_passphrase, +) + +OFFICIAL_SIGNATURE = { + "valid": True, + "ad_hoc": False, + "hardened_runtime": True, + "team_identifier": "5A4RE8SF68", + "identifier": "com.tencent.xinWeChat", + "cdhash": "abc123", +} +DEBUG_SIGNATURE = { + "valid": True, + "ad_hoc": True, + "hardened_runtime": False, + "team_identifier": "", + "identifier": "com.tencent.xinWeChat", + "cdhash": "debug123", +} + + +class TestMacOSDBKeyCapture(unittest.TestCase): + def test_in_place_preparation_records_recovery_before_signing(self) -> None: + official = Path("/tmp/WeChat.app") + backup = Path("/Volumes/BackupVolume/backups/WeChat-original.zip") + events: list[str] = [] + + def record(payload): + self.assertEqual(payload["backup_path"], str(backup)) + events.append("state") + + sign_commands: list[list[str]] = [] + + def run(args, **_kwargs): + if args[0] == "/usr/bin/codesign": + events.append("sign") + sign_commands.append(args) + return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") + + with ( + patch( + "wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", + side_effect=[OFFICIAL_SIGNATURE, DEBUG_SIGNATURE, DEBUG_SIGNATURE], + ), + patch("wechat_decrypt_tool.macos_db_key_capture.os.access", return_value=True), + patch("wechat_decrypt_tool.macos_db_key_capture.backup_original_wechat", return_value=(backup, False)), + patch( + "wechat_decrypt_tool.macos_db_key_capture.verify_original_wechat_backup", + return_value={"version": "4.1.12", "build": "269341", "cdhash": "abc123"}, + ), + patch("wechat_decrypt_tool.macos_db_key_capture._wechat_version", return_value=("4.1.12", "269341")), + patch("wechat_decrypt_tool.macos_db_key_capture._quit_wechat"), + patch( + "wechat_decrypt_tool.macos_db_key_capture._prepare_local_restore_staging", + return_value=Path("/tmp/staged.app"), + ), + patch("wechat_decrypt_tool.macos_db_key_capture._has_compatible_in_place_signature", return_value=True), + patch("wechat_decrypt_tool.macos_db_key_capture._atomic_swap_paths") as swap, + patch("wechat_decrypt_tool.macos_db_key_capture._run", side_effect=run), + ): + result = ensure_wechat_in_place_debuggable(official, backup.parent, before_resign=record) + + self.assertEqual(events, ["state", "sign"]) + self.assertNotIn("--deep", sign_commands[0]) + self.assertIn("--preserve-metadata=entitlements", sign_commands[0]) + self.assertEqual(sign_commands[0][-1], "/tmp/staged.app") + swap.assert_called_once_with(official, Path("/tmp/staged.app")) + self.assertTrue(result["wechat_resigned"]) + self.assertTrue(result["backup_verified"]) + + def test_in_place_preparation_rejects_backup_identity_mismatch_before_mutation(self) -> None: + official = Path("/tmp/WeChat.app") + backup = Path("/Volumes/BackupVolume/backups/WeChat-original.zip") + with ( + patch("wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE), + patch("wechat_decrypt_tool.macos_db_key_capture.os.access", return_value=True), + patch("wechat_decrypt_tool.macos_db_key_capture.backup_original_wechat", return_value=(backup, False)), + patch( + "wechat_decrypt_tool.macos_db_key_capture.verify_original_wechat_backup", + return_value={"version": "4.1.12", "build": "269341", "cdhash": "different"}, + ), + patch("wechat_decrypt_tool.macos_db_key_capture._wechat_version", return_value=("4.1.12", "269341")), + patch("wechat_decrypt_tool.macos_db_key_capture._quit_wechat") as quit_wechat, + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + ensure_wechat_in_place_debuggable(official, backup.parent) + + self.assertEqual(context.exception.code, "official_backup_identity_mismatch") + quit_wechat.assert_not_called() + + def test_sign_failure_restores_verified_official(self) -> None: + official = Path("/tmp/WeChat.app") + backup = Path("/Volumes/BackupVolume/backups/WeChat-original.zip") + sign_failure = MacOSDBKeyCaptureFailure("command_failed", "sign failed") + with ( + patch("wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE), + patch("wechat_decrypt_tool.macos_db_key_capture.os.access", return_value=True), + patch("wechat_decrypt_tool.macos_db_key_capture.backup_original_wechat", return_value=(backup, False)), + patch( + "wechat_decrypt_tool.macos_db_key_capture.verify_original_wechat_backup", + return_value={"version": "4.1.12", "build": "269341", "cdhash": "abc123"}, + ), + patch("wechat_decrypt_tool.macos_db_key_capture._wechat_version", return_value=("4.1.12", "269341")), + patch("wechat_decrypt_tool.macos_db_key_capture._quit_wechat"), + patch("wechat_decrypt_tool.macos_db_key_capture._prepare_local_restore_staging"), + patch("wechat_decrypt_tool.macos_db_key_capture._run", side_effect=sign_failure), + patch("wechat_decrypt_tool.macos_db_key_capture._run_as_administrator", side_effect=sign_failure), + patch( + "wechat_decrypt_tool.macos_db_key_capture.restore_official_wechat_if_needed", + return_value={"official_wechat_verified": True, "official_wechat_restored": True}, + ) as restore, + ): + with self.assertRaises(MacOSDBKeyCaptureFailure): + ensure_wechat_in_place_debuggable(official, backup.parent) + + restore.assert_called_once() + + @unittest.skipUnless(sys.platform == "darwin", "renameatx_np is macOS-specific") + def test_atomic_restore_exchange_has_no_missing_path_window(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + installed = root / "WeChat.app" + staged = root / ".WeChat.restore.app" + installed.mkdir() + staged.mkdir() + (installed / "marker").write_text("debug", encoding="utf-8") + (staged / "marker").write_text("official", encoding="utf-8") + + _atomic_swap_paths(installed, staged) + + self.assertEqual((installed / "marker").read_text(encoding="utf-8"), "official") + self.assertEqual((staged / "marker").read_text(encoding="utf-8"), "debug") + + def test_quit_timeout_falls_back_without_blocking_restore(self) -> None: + official = Path("/Applications/WeChat.app") + timeout = subprocess.TimeoutExpired(cmd="osascript", timeout=5) + completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + with ( + patch("wechat_decrypt_tool.macos_db_key_capture._find_wechat_main_pid", side_effect=[123, 123, None]), + patch("wechat_decrypt_tool.macos_db_key_capture._find_wechat_bundle_pids", return_value=[]), + patch("wechat_decrypt_tool.macos_db_key_capture.time.monotonic", side_effect=[0, 16, 16, 32, 32, 33]), + patch("wechat_decrypt_tool.macos_db_key_capture.subprocess.run", side_effect=[timeout, completed]) as run, + ): + _quit_wechat(official) + + self.assertEqual(run.call_args_list[1].args[0], ["/bin/kill", "-TERM", "123"]) + + def test_parses_exact_32_byte_lldb_memory_dump(self) -> None: + output = "\n".join( + [ + "0x1000: " + " ".join(f"0x{value:02x}" for value in range(16)), + "0x1010: " + " ".join(f"0x{value:02x}" for value in range(16, 32)), + ] + ) + self.assertEqual(_parse_passphrase(output), bytes(range(32)).hex()) + + def test_rejects_incomplete_lldb_memory_dump(self) -> None: + self.assertEqual(_parse_passphrase("0x1000: 0x01 0x02"), "") + + def test_lldb_wrapper_stops_keepalive_when_debugger_exits(self) -> None: + command = _build_lldb_capture_command(Path("/tmp/capture script.lldb"), 180) + for expected in ("/usr/bin/mkfifo", "producer_pid", "watchdog_pid", "lldb_pid", "/bin/kill", "WEDATA_LLDB_EXIT"): + self.assertIn(expected, command) + + def test_ad_hoc_debug_copy_must_not_claim_any_entitlements(self) -> None: + incompatible = subprocess.CompletedProcess( + args=[], returncode=0, + stdout="com.apple.application-identifier5A4RE8SF68.com.tencent.xinWeChat", stderr="", + ) + compatible = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="warning: blob data is NULL") + debug_app = Path("/tmp/WeChat-Debug.app") + with patch.object(Path, "exists", return_value=True), patch( + "wechat_decrypt_tool.macos_db_key_capture._run", return_value=incompatible + ): + self.assertFalse(_has_compatible_debug_entitlements(debug_app)) + with patch.object(Path, "exists", return_value=True), patch( + "wechat_decrypt_tool.macos_db_key_capture._run", return_value=compatible + ): + self.assertTrue(_has_compatible_debug_entitlements(debug_app)) + + def test_in_place_signature_keeps_tencent_login_helper(self) -> None: + app = Path("/Applications/WeChat.app") + required_entitlements = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=""" + +com.apple.application-identifier5A4RE8SF68.com.tencent.xinWeChat +com.apple.security.app-sandbox +com.apple.security.application-groups5A4RE8SF68.com.tencent.xinWeChat +com.apple.security.network.client +""", + stderr="", + ) + helper_signature = { + "valid": True, + "ad_hoc": False, + "team_identifier": "5A4RE8SF68", + "identifier": "com.tencent.flue.WeChatAppEx", + } + with ( + patch("wechat_decrypt_tool.macos_db_key_capture._run", return_value=required_entitlements), + patch.object(Path, "exists", return_value=True), + patch("wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", return_value=helper_signature), + ): + self.assertTrue(_has_compatible_in_place_signature(app)) + + def test_in_place_signature_rejects_missing_outer_sandbox_entitlements(self) -> None: + app = Path("/Applications/WeChat.app") + missing_entitlements = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="warning: blob data is NULL" + ) + with patch( + "wechat_decrypt_tool.macos_db_key_capture._run", return_value=missing_entitlements + ): + self.assertFalse(_has_compatible_in_place_signature(app)) + + @unittest.skipUnless(sys.platform == "darwin", "OpenStep InfoPlist.strings conversion requires plutil") + def test_debug_copy_has_an_unambiguous_visible_name(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app = Path(temp_dir) / "WeChat-Debug.app" + info_path = app / "Contents/Info.plist" + localized_path = app / "Contents/Resources/zh-Hans.lproj/InfoPlist.strings" + info_path.parent.mkdir(parents=True) + info_path.write_bytes(plistlib.dumps({"CFBundleIdentifier": "com.tencent.xinWeChat"})) + localized_path.parent.mkdir(parents=True) + localized_path.write_text('"CFBundleDisplayName" = "微信";\n', encoding="utf-8") + self.assertFalse(_has_debug_copy_marker(app)) + _mark_debug_copy(app) + self.assertTrue(_has_debug_copy_marker(app)) + + def test_saves_passphrase_atomically_with_private_permissions(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + target = save_passphrase("ab" * 32, home=Path(temp_dir)) + self.assertEqual(json.loads(target.read_text(encoding="utf-8"))["passphrase"], "ab" * 32) + self.assertEqual(os.stat(target).st_mode & 0o777, 0o600) + + def test_rejects_invalid_passphrase_before_writing(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + save_passphrase("not-a-key", home=Path(temp_dir)) + self.assertEqual(context.exception.code, "invalid_passphrase") + + def test_nas_backup_is_a_metadata_preserving_zip(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + app = root / "WeChat.app" + contents = app / "Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_bytes( + plistlib.dumps({"CFBundleShortVersionString": "4.1.12", "CFBundleVersion": "269341"}) + ) + (contents / "marker.txt").write_text("original", encoding="utf-8") + backup, created = backup_original_wechat(app, root / "backups") + self.assertTrue(created) + reused, created_again = backup_original_wechat(app, root / "backups") + self.assertEqual(reused, backup) + self.assertFalse(created_again) + + def test_debug_preparation_preserves_official_wechat(self) -> None: + official = Path("/Applications/WeChat.app") + backup = Path("/Volumes/BackupVolume/WeChat-original.zip") + debug = Path("/Users/demo/Library/Caches/WeChatDataAnalysis/WeChat-Debug.app") + with ( + patch("wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE), + patch("wechat_decrypt_tool.macos_db_key_capture.backup_original_wechat", return_value=(backup, False)), + patch("wechat_decrypt_tool.macos_db_key_capture._prepare_debug_copy", return_value=(debug, True)), + ): + result = ensure_wechat_debuggable(official, backup.parent) + self.assertFalse(result["wechat_modified"]) + self.assertTrue(result["official_wechat_preserved"]) + + def test_official_restore_skips_already_valid_tencent_build(self) -> None: + with patch("wechat_decrypt_tool.macos_db_key_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE), patch.object( + Path, "exists", return_value=False + ): + result = restore_official_wechat_if_needed(Path("/tmp/WeChat.app"), Path("/tmp/original.zip")) + self.assertTrue(result["official_wechat_verified"]) + self.assertFalse(result["official_wechat_restored"]) + + def test_capture_delegates_to_recoverable_in_place_workflow(self) -> None: + official = Path("/Applications/WeChat.app") + expected = {"cache_path": "/tmp/key.json", "official_wechat_preserved": True} + with ( + patch("wechat_decrypt_tool.macos_inplace_capture.prepare_in_place_capture") as prepare, + patch("wechat_decrypt_tool.macos_inplace_capture.preflight_prepared_in_place_capture") as preflight, + patch("wechat_decrypt_tool.macos_inplace_capture.capture_prepared_in_place", return_value=expected) as capture, + ): + result = capture_and_cache_macos_passphrase( + official, backup_root=Path("/tmp/backups"), probe_db_path=Path("/tmp/message_0.db") + ) + prepare.assert_called_once() + preflight.assert_called_once() + capture.assert_called_once() + self.assertEqual(result, expected) + + def test_cleanup_delegates_to_in_place_recovery(self) -> None: + official = Path("/Applications/WeChat.app") + expected = {"official_wechat_verified": True, "wechat_modified": False} + with patch("wechat_decrypt_tool.macos_inplace_capture.cleanup_in_place_capture", return_value=expected) as cleanup: + result = cleanup_macos_passphrase_capture(official, backup_root=Path("/tmp/backups")) + cleanup.assert_called_once() + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_macos_db_key_discovery.py b/tests/test_macos_db_key_discovery.py new file mode 100644 index 00000000..fa0c1817 --- /dev/null +++ b/tests/test_macos_db_key_discovery.py @@ -0,0 +1,110 @@ +import hashlib +import hmac +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool.macos_db_key_discovery import ( + MacOSDBKeyDiscoveryFailure, + discover_macos_db_key, +) +from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE + + +def encrypted_page_for_passphrase(passphrase: bytes, salt: bytes = bytes(range(16))) -> bytes: + enc_key = hashlib.pbkdf2_hmac("sha512", passphrase, salt, 256000, dklen=32) + mac_salt = bytes(value ^ 0x3A for value in salt) + mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=32) + page = bytearray(PAGE_SIZE) + page[:16] = salt + page[16 : PAGE_SIZE - RESERVE_SIZE + 16] = bytes([0x5A]) * (PAGE_SIZE - RESERVE_SIZE) + digest = hmac.new(mac_key, digestmod=hashlib.sha512) + digest.update(page[16 : PAGE_SIZE - RESERVE_SIZE + 16]) + digest.update((1).to_bytes(4, "little")) + page[PAGE_SIZE - 64 :] = digest.digest() + return bytes(page) + + +class TestMacOSDBKeyDiscovery(unittest.TestCase): + def test_discovers_wcdb_key_tool_passphrase_and_validates_it(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + db = root / "account" / "db_storage" / "message" / "message_0.db" + db.parent.mkdir(parents=True) + passphrase = bytes(range(32)) + db.write_bytes(encrypted_page_for_passphrase(passphrase)) + cache = root / ".wcdb-key-tool" / "wechat-passphrase.json" + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps({"passphrase": passphrase.hex()}), encoding="utf-8") + + with patch("sys.platform", "darwin"): + result = discover_macos_db_key(db, home=root) + + self.assertEqual(result["db_key"], passphrase.hex()) + self.assertEqual(result["key_mode"], "sqlcipher_passphrase") + self.assertFalse(result["wechat_modified"]) + self.assertFalse(result["process_attached"]) + + def test_rejects_unverified_candidate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + db = root / "db_storage" / "message_0.db" + db.parent.mkdir(parents=True) + db.write_bytes(encrypted_page_for_passphrase(bytes(range(32)))) + cache = root / ".wcdb-key-tool" / "wechat-passphrase.json" + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps({"passphrase": "ff" * 32}), encoding="utf-8") + + with patch("sys.platform", "darwin"): + with self.assertRaises(MacOSDBKeyDiscoveryFailure) as context: + discover_macos_db_key(db, home=root) + + self.assertEqual(context.exception.code, "safe_key_not_found") + + def test_cache_miss_guidance_describes_recoverable_in_place_capture(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + db = root / "db_storage" / "message_0.db" + db.parent.mkdir(parents=True) + db.write_bytes(encrypted_page_for_passphrase(bytes(range(32)))) + + with patch("sys.platform", "darwin"): + with self.assertRaises(MacOSDBKeyDiscoveryFailure) as context: + discover_macos_db_key(db, home=root) + + message = str(context.exception) + self.assertEqual(context.exception.code, "safe_key_not_found") + self.assertIn("所选备份目录验证腾讯原版备份", message) + self.assertIn("默认路径微信", message) + self.assertIn("同卷 APFS 写时复制恢复副本", message) + self.assertIn("临时启用调试签名", message) + self.assertIn("完成断点预检并分离后先退出账号", message) + self.assertIn("随后只重新登录同一个账号", message) + self.assertIn("当前数据库校验", message) + self.assertIn("恢复腾讯原签名", message) + self.assertIn("清理临时恢复副本", message) + self.assertNotIn("WeChat Debug - WCDA", message) + + def test_reports_full_disk_access_requirement_on_permission_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + db = Path(tmp) / "session.db" + db.write_bytes(encrypted_page_for_passphrase(bytes(range(32)))) + with ( + patch("sys.platform", "darwin"), + patch.object(Path, "open", side_effect=PermissionError(1, "Operation not permitted")), + ): + with self.assertRaises(MacOSDBKeyDiscoveryFailure) as context: + discover_macos_db_key(db, home=Path(tmp)) + + self.assertEqual(context.exception.code, "database_permission_denied") + self.assertIn("完全磁盘访问权限", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_macos_inplace_capture.py b/tests/test_macos_inplace_capture.py new file mode 100644 index 00000000..81eb2879 --- /dev/null +++ b/tests/test_macos_inplace_capture.py @@ -0,0 +1,164 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool.macos_db_key_capture import MacOSDBKeyCaptureFailure +from wechat_decrypt_tool.macos_inplace_capture import ( + _read_state, + _safe_backup_from_state, + _write_state, + capture_prepared_in_place, + cleanup_in_place_capture, + recover_stale_in_place_capture, +) + +OFFICIAL_SIGNATURE = { + "valid": True, + "ad_hoc": False, + "team_identifier": "5A4RE8SF68", + "identifier": "com.tencent.xinWeChat", +} + + +class TestMacOSInPlaceCapture(unittest.TestCase): + def test_state_is_atomic_private_and_non_secret(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + target = _write_state( + root, + { + "schema_version": 1, + "wechat_app_path": "/Applications/WeChat.app", + "backup_path": "/Volumes/BackupVolume/backups/WeChat-original.zip", + }, + ) + payload = json.loads(target.read_text(encoding="utf-8")) + self.assertNotIn("passphrase", payload) + self.assertNotIn("key", payload) + self.assertEqual(os.stat(target).st_mode & 0o777, 0o600) + self.assertEqual(_read_state(root), payload) + + def test_backup_path_validation_does_not_require_nas_online(self) -> None: + root = Path("/Volumes/BackupVolume/WCDA/output/wechat-app-backups") + expected = root / "WeChat-4.1.12-269341-original.zip" + self.assertEqual(_safe_backup_from_state({"backup_path": str(expected)}, root), expected) + + def test_backup_path_escape_is_rejected(self) -> None: + root = Path("/Volumes/BackupVolume/WCDA/output/wechat-app-backups") + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + _safe_backup_from_state({"backup_path": "/tmp/WeChat-original.zip"}, root) + self.assertEqual(context.exception.code, "official_backup_path_unsafe") + + def test_stale_state_restores_then_removes_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + debug_root = Path(temp_dir) + backup_root = debug_root / "backups" + backup_root.mkdir() + backup = backup_root / "WeChat-4.1.12-original.zip" + backup.touch() + _write_state( + debug_root, + { + "schema_version": 1, + "wechat_app_path": "/Applications/WeChat.app", + "backup_path": str(backup), + "version": "4.1.12", + "build": "269341", + "official_cdhash": "abc123", + }, + ) + with ( + patch("wechat_decrypt_tool.macos_inplace_capture.normalize_wechat_app_path", return_value=Path("/Applications/WeChat.app")), + patch( + "wechat_decrypt_tool.macos_inplace_capture.restore_official_wechat_if_needed", + return_value={"official_wechat_verified": True, "official_wechat_restored": True}, + ) as restore, + patch("wechat_decrypt_tool.macos_inplace_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE), + ): + result = recover_stale_in_place_capture( + "/Applications/WeChat.app", backup_root=backup_root, debug_root=debug_root + ) + self.assertTrue(result["official_wechat_restored"]) + self.assertFalse((debug_root / "prepared-in-place-capture.json").exists()) + restore.assert_called_once() + + def test_cancel_without_state_verifies_official_without_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir, patch( + "wechat_decrypt_tool.macos_inplace_capture.normalize_wechat_app_path", return_value=Path("/Applications/WeChat.app") + ), patch("wechat_decrypt_tool.macos_inplace_capture.inspect_wechat_signature", return_value=OFFICIAL_SIGNATURE): + result = cleanup_in_place_capture( + "/Applications/WeChat.app", backup_root=Path(temp_dir) / "backups", debug_root=Path(temp_dir) + ) + self.assertTrue(result["official_wechat_verified"]) + self.assertFalse(result["official_wechat_restored"]) + + def test_capture_failure_always_requests_restore(self) -> None: + debug_root = Path("/tmp/wcda-inplace-test") + official = Path("/Applications/WeChat.app") + failure = MacOSDBKeyCaptureFailure("debug_wechat_not_running", "closed", wechat_modified=True) + with ( + patch("wechat_decrypt_tool.macos_inplace_capture.normalize_wechat_app_path", return_value=official), + patch("wechat_decrypt_tool.macos_inplace_capture._require_prepared_process", side_effect=failure), + patch("wechat_decrypt_tool.macos_inplace_capture.has_pending_in_place_capture", return_value=True), + patch("wechat_decrypt_tool.macos_inplace_capture._restore_after_terminal_path") as restore, + ): + with self.assertRaises(MacOSDBKeyCaptureFailure): + capture_prepared_in_place( + official, + backup_root=Path("/Volumes/BackupVolume/backups"), + probe_db_path=Path("/tmp/message_0.db"), + debug_root=debug_root, + ) + restore.assert_called_once() + + def test_capture_arms_resolved_internal_return_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + debug_root = Path(temp_dir) + probe = debug_root / "message_0.db" + probe.write_bytes(bytes(range(256)) * 16) + (debug_root / "breakpoint-preflight.json").write_text( + json.dumps({"pid": 321, "pbkdf_locations": 1, "key_return_locations": 1}), + encoding="utf-8", + ) + with ( + patch( + "wechat_decrypt_tool.macos_inplace_capture.normalize_wechat_app_path", + return_value=Path("/Applications/WeChat.app"), + ), + patch( + "wechat_decrypt_tool.macos_inplace_capture._require_prepared_process", + return_value=({}, 321), + ), + patch( + "wechat_decrypt_tool.macos_inplace_capture.capture_salt_matched_passphrase", + return_value="ab" * 32, + ) as capture, + patch("wechat_decrypt_tool.macos_inplace_capture._validate_captured_passphrase"), + patch( + "wechat_decrypt_tool.macos_inplace_capture.save_passphrase", + return_value=debug_root / "key.json", + ), + patch( + "wechat_decrypt_tool.macos_inplace_capture._restore_after_terminal_path", + return_value={"official_wechat_verified": True, "official_wechat_restored": True}, + ), + ): + capture_prepared_in_place( + "/Applications/WeChat.app", + backup_root=debug_root / "backups", + probe_db_path=probe, + debug_root=debug_root, + ) + + self.assertTrue(capture.call_args.kwargs["enable_key_return_fallback"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_macos_key_capture_release_audit.py b/tests/test_macos_key_capture_release_audit.py new file mode 100644 index 00000000..df966477 --- /dev/null +++ b/tests/test_macos_key_capture_release_audit.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import importlib.util +import zipfile +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "audit_macos_key_capture_release.py" +_SPEC = importlib.util.spec_from_file_location("audit_macos_key_capture_release", _SCRIPT) +assert _SPEC is not None and _SPEC.loader is not None +_AUDIT = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_AUDIT) + + +def test_release_audit_rejects_personal_paths_and_runtime_secrets(tmp_path: Path) -> None: + app = tmp_path / "Example.app" + app.mkdir() + (app / "binary").write_bytes(b"built from /Users/example/private/source.py") + (app / "wechat-passphrase.json").write_text("{}", encoding="utf-8") + + violations = _AUDIT.audit_directory(app) + + assert any("用户主目录绝对路径" in item for item in violations) + assert any("私密文件" in item for item in violations) + + +def test_release_audit_accepts_clean_app_and_rejects_database_in_zip(tmp_path: Path) -> None: + app = tmp_path / "Clean.app" + app.mkdir() + (app / "binary").write_bytes(b"portable executable content") + assert _AUDIT.audit_directory(app) == [] + + archive = tmp_path / "release.zip" + with zipfile.ZipFile(archive, "w") as output: + output.writestr("Clean.app/Contents/MacOS/binary", b"clean") + output.writestr("Clean.app/user/message_0.db", b"private") + assert any("私密文件" in item for item in _AUDIT.audit_zip(archive)) + + +def test_release_audit_scans_zip_entry_contents(tmp_path: Path) -> None: + archive = tmp_path / "release.zip" + with zipfile.ZipFile(archive, "w") as output: + output.writestr( + "Example.app/Contents/Resources/build.txt", + b"built from /Users/example/private/source.py", + ) + + assert any("用户主目录绝对路径" in item for item in _AUDIT.audit_zip(archive)) diff --git a/tools/audit_macos_key_capture_release.py b/tools/audit_macos_key_capture_release.py new file mode 100644 index 00000000..ca9d4e04 --- /dev/null +++ b/tools/audit_macos_key_capture_release.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import argparse +import os +import re +import zipfile +from collections.abc import Iterable +from pathlib import Path + +SENSITIVE_ENTRY_NAMES = { + ".env", + "account_keys.json", + "desktop-settings.json", + "preferences.json", + "wechat-passphrase.json", +} +SENSITIVE_SUFFIXES = {".db", ".log"} +PERSONAL_PATH_PATTERN = re.compile(rb"/(?:Users|home)/[^/\x00\r\n\t ]+/") +MACHO_MAGICS = { + b"\xca\xfe\xba\xbe", + b"\xcf\xfa\xed\xfe", + b"\xce\xfa\xed\xfe", + b"\xfe\xed\xfa\xcf", + b"\xfe\xed\xfa\xce", +} + + +def _entry_is_sensitive(name: str) -> bool: + path = Path(name) + return path.name.lower() in SENSITIVE_ENTRY_NAMES or path.suffix.lower() in SENSITIVE_SUFFIXES + + +def _personal_markers() -> tuple[bytes, ...]: + markers: set[bytes] = set() + home = str(Path.home()) + if home not in {"", "/"}: + markers.add(home.encode("utf-8")) + username = os.environ.get("USER", "").strip() + if len(username) >= 3: + markers.add(f"/{username}/".encode()) + return tuple(sorted(markers)) + + +def _content_violations(label: str, payload: bytes) -> list[str]: + violations: list[str] = [] + # Prebuilt extension modules commonly retain harmless CI paths such as + # /Users/runner. We still scan every byte for this builder's exact home and + # username, while applying the generic home-directory rule to non-Mach-O + # code and resources where a path would indicate accidental bundling. + is_macho = payload[:4] in MACHO_MAGICS + if not is_macho and PERSONAL_PATH_PATTERN.search(payload): + violations.append(f"{label}: 包含用户主目录绝对路径") + for marker in _personal_markers(): + if marker in payload: + violations.append(f"{label}: 包含当前构建用户信息") + break + return violations + + +def audit_directory(root: Path) -> list[str]: + violations: list[str] = [] + if not root.is_dir(): + return [f"目录不存在: {root}"] + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix() + if _entry_is_sensitive(relative): + violations.append(f"{relative}: 不应打包运行时数据或私密文件") + continue + try: + violations.extend(_content_violations(relative, path.read_bytes())) + except OSError as exc: + violations.append(f"{relative}: 无法审计: {exc}") + return violations + + +def audit_zip(path: Path) -> list[str]: + violations: list[str] = [] + if not path.is_file(): + return [f"安装包不存在: {path}"] + try: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + if _entry_is_sensitive(info.filename): + violations.append(f"{info.filename}: 不应打包运行时数据或私密文件") + continue + try: + violations.extend( + _content_violations(info.filename, archive.read(info)) + ) + except (OSError, RuntimeError, zipfile.BadZipFile) as exc: + violations.append(f"{info.filename}: 无法审计: {exc}") + except (OSError, zipfile.BadZipFile) as exc: + violations.append(f"无法读取安装包 {path}: {exc}") + return violations + + +def run_audit(paths: Iterable[Path]) -> list[str]: + violations: list[str] = [] + for path in paths: + if path.is_dir(): + violations.extend(audit_directory(path)) + elif path.suffix.lower() == ".zip": + violations.extend(audit_zip(path)) + else: + violations.append(f"不支持的审计目标: {path}") + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description="检查 macOS 安装包是否混入个人路径或运行时私密文件") + parser.add_argument("paths", nargs="+", type=Path) + args = parser.parse_args() + violations = run_audit(args.paths) + if violations: + print("发布审计失败:") + for item in violations: + print(f"- {item}") + return 1 + print("发布审计通过:未发现个人路径、数据库、日志或密钥缓存文件。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e33b12479964eabae48472a9681c6a08f3d42d0d Mon Sep 17 00:00:00 2001 From: coderscsy Date: Sat, 22 Aug 2026 14:36:42 +0800 Subject: [PATCH 2/4] fix(macos): handle WCDB rounds-2 capture --- docs/macos-wcdb-key-capture-validation.md | 10 +- docs/macos-wcdb-key-capture.md | 9 +- .../macos_clone_capture.py | 75 +++++++++- tests/test_macos_clone_capture.py | 134 +++++++++++++++++- 4 files changed, 214 insertions(+), 14 deletions(-) diff --git a/docs/macos-wcdb-key-capture-validation.md b/docs/macos-wcdb-key-capture-validation.md index 6a480abd..95927641 100644 --- a/docs/macos-wcdb-key-capture-validation.md +++ b/docs/macos-wcdb-key-capture-validation.md @@ -28,6 +28,8 @@ | 临时签名失败 | 对签名命令注入异常 | 未完成安全换位时不改动官方微信;已换位时进入统一恢复 | | 断点预检失败 | 模拟无 PBKDF2/内部返回断点 | 立即停止,不要求用户退出账号,cleanup 恢复 | | 捕获超时 | 模拟 LLDB 超时且无合格候选 | 不保存候选,`finally` 恢复官方微信 | +| 隔离副本提前退出 | 模拟 LLDB 报告进程已退出及退出码 | 立即返回 `debug_wechat_exited_during_capture` 和非敏感断点计数,不继续等待普通超时 | +| 微信 4.1.13 `rounds=2` | 模拟目标库 HMAC salt 与 32 字节主密钥候选 | 只接受精确 HMAC salt 映射且通过目标数据库首页 HMAC 的候选;拒绝其他轮数 | | 数据库不匹配 | 使用无法通过目标首页 HMAC 的 32 字节候选 | 返回 `passphrase_database_mismatch`,不写缓存,恢复官方微信 | | 进程/应用异常退出 | 保留已 fsync 的事务状态后重新进入 | 下次启动优先调用 stale recovery,状态目标和备份路径校验后恢复 | | 备份路径篡改 | 将状态中的备份改到所选目录之外 | 拒绝覆盖微信并保留可诊断状态 | @@ -39,12 +41,12 @@ 在官方 `main` 基线的隔离工作树中运行: ```text -48 passed in 1.10s +50 passed in 1.21s ``` -覆盖范围包括官方备份/版本校验、原子换位、调试 entitlement、LLDB 输出解析、PBKDF2 参数和 salt 过滤、内部返回点预检、候选数据库验真、状态文件权限、取消/超时/异常恢复、活动库优先和发布审计。 +覆盖范围包括官方备份/版本校验、原子换位、调试 entitlement、LLDB 输出解析、`rounds=256000` 数据库 salt 与 `rounds=2` HMAC salt 精确过滤、内部返回点预检、候选数据库验真、隔离副本提前退出诊断、状态文件权限、取消/超时/异常恢复、活动库优先和发布审计。 -完整测试也在同一 Python 环境中对官方基线和本 Draft 分别执行。官方 `f6583c5` 基线为 `828 passed, 110 failed, 9 errors, 2 skipped`;本 Draft 为 `876 passed, 110 failed, 9 errors, 2 skipped`。新增 48 项全部通过,失败/错误数量与基线完全一致。现有失败集中于本机未提供仓库受限 native core、导出完整性运行时和语音模型环境,因此不把它们误报为本 PR 引入的回归。 +原始 Draft 曾在同一 Python 环境中对官方基线和 Draft 分别执行完整测试:官方 `f6583c5` 基线为 `828 passed, 110 failed, 9 errors, 2 skipped`,原始 Draft 为 `876 passed, 110 failed, 9 errors, 2 skipped`,其新增 48 项全部通过且原有失败/错误数量不变。本次根据微信 4.1.13 社区反馈新增 2 项回归后,已重新执行上述 5 个定向模块并得到 50 项通过;完整仓库测试尚未重跑,不用历史结果冒充本次结果。历史失败集中于本机未提供仓库受限 native core、导出完整性运行时和语音模型环境。 ## 发布包审计结果 @@ -60,7 +62,7 @@ SHA-256: 5d6f4e1a4dffac147fe2dff717674fa5061c7e0179e30c083621cb831d3873cb ## 限制和后续评审点 -- 当前真实记录不能推断 Intel Mac 或未来微信版本已兼容; +- 当前真实记录不能推断 Intel Mac 或未来微信版本已兼容;微信 4.1.13 build 269578 的 `rounds=2` 行为来自社区实测,当前仅完成可复现逻辑回归,尚未完成该 build 的 clone 成功路径复测; - 当前 Draft 故意不替换现有 helper,也不改变默认 UI/API; - 维护者确认状态机后,应在后续小 PR 中决定调用模式、前端确认步骤和正式 Release 的 Developer ID/公证流程; - 发布前必须在上游正式构建产物上再次执行同一审计,不能用本地验证包结果替代正式 Release 审计。 diff --git a/docs/macos-wcdb-key-capture.md b/docs/macos-wcdb-key-capture.md index 6fe5af93..6040ac9e 100644 --- a/docs/macos-wcdb-key-capture.md +++ b/docs/macos-wcdb-key-capture.md @@ -20,9 +20,10 @@ 3. 在任何改动前创建并验证官方微信恢复归档,再将本机恢复状态以 `0600` 原子写入磁盘。 4. 只对一次捕获所需的临时实例进行调试签名。先登录进入聊天页,再短暂附加做断点预检并立即分离。 5. 用户在未监测状态退出账号;正式监测开始后重新登录同一账号。 -6. 通用路径只接受符合 WCDB 参数形状且 salt 命中的 PBKDF2 调用;已知版本还可使用按模块 UUID 明确登记的内部返回点。未知 UUID 不猜偏移。 +6. 通用路径只接受两种经过验证的 WCDB 参数形状:`rounds=256000` 时 salt 必须等于目标数据库 salt;`rounds=2` 时 salt 必须等于目标数据库 salt 逐字节异或 `0x3A` 后的 HMAC salt。其他轮数一律拒绝。已知版本还可使用按模块 UUID 明确登记的内部返回点,未知 UUID 不猜偏移。 7. 32 字节候选必须通过目标数据库首页 HMAC 校验才会保存,缓存目录和文件分别使用 `0700`、`0600`。 -8. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。 +8. LLDB 在 `process continue` 返回后记录隔离微信的 PID、进程状态、退出码和不超过 240 字符的退出原因;副本提前退出时立即返回专用诊断,不继续等待为普通超时。 +9. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。 ## 源码复现 @@ -96,6 +97,8 @@ python tools/audit_macos_key_capture_release.py \ ## 当前兼容性 - 已验证:Apple Silicon、微信 4.1.12(build 269341)、活动 `Documents/app_data/xwechat_files` 数据库; -- 未验证:Intel Mac、其他微信版本、多套并行安装; +- 社区实测反馈:微信 4.1.13(build 269578)出现 `rounds=2` 的页 HMAC 子密钥派生调用,其 32 字节 password 可作为数据库主密钥候选;本实现仅在 HMAC salt 精确对应目标库且候选通过该库首页 HMAC 时接受; +- 同一社区环境报告隔离副本在手机确认登录后静默退出。当前修复增加退出状态诊断,但尚未在该机器上证明副本登录问题已经消失; +- 未验证:Intel Mac、微信 4.1.13 build 269578 的完整 clone 成功路径、未来微信版本、多套并行安装; - 已知内部返回点仅按 Mach-O UUID 精确启用;版本未知时只保留系统 PBKDF2 路径并返回明确诊断; - 断点预检不通过时流程必须停止,不能要求用户继续退出账号。 diff --git a/src/wechat_decrypt_tool/macos_clone_capture.py b/src/wechat_decrypt_tool/macos_clone_capture.py index 1140ec5d..1301ed92 100644 --- a/src/wechat_decrypt_tool/macos_clone_capture.py +++ b/src/wechat_decrypt_tool/macos_clone_capture.py @@ -3,8 +3,8 @@ The Tencent-signed application is never modified. A disposable ad-hoc copy runs with a private HOME containing a copy-on-write clone of the real WeChat container. LLDB is attached only to that disposable process and accepts a -PBKDF2 password only when both the SQLCipher iteration count and database salt -match databases in the cloned container. +PBKDF2 password only when the KDF profile and salt match a cloned database and +the candidate passes that database's page-one HMAC verification. """ from __future__ import annotations @@ -380,6 +380,13 @@ def build_lldb_salt_capture_script( raise MacOSDBKeyCaptureFailure("capture_salts_missing", "没有可用于过滤 PBKDF2 调用的数据库 salt") result_literal = json.dumps(str(result_path)) salts_literal = json.dumps(salts) + hmac_salts_literal = json.dumps( + { + bytes(value ^ 0x3A for value in bytes.fromhex(salt)).hex(): salt + for salt in salts + }, + sort_keys=True, + ) page1_literal = json.dumps(bytes(probe_page1 or b"").hex()) return f'''import hashlib import hmac @@ -389,6 +396,7 @@ def build_lldb_salt_capture_script( RESULT_PATH = {result_literal} EXPECTED_SALTS = frozenset({salts_literal}) +EXPECTED_HMAC_SALTS = {hmac_salts_literal} PROBE_PAGE1 = bytes.fromhex({page1_literal}) KEY_RETURN_POINTS = {json.dumps(WECHAT_KEY_RETURN_POINTS)} ENABLE_KEY_RETURN_FALLBACK = {bool(enable_key_return_fallback)!r} @@ -396,6 +404,8 @@ def build_lldb_salt_capture_script( DIAGNOSTICS = {{ "pbkdf_calls": 0, "pbkdf_shape_hits": 0, + "pbkdf_rounds_2_hits": 0, + "pbkdf_rounds_256000_hits": 0, "pbkdf_salt_hits": 0, "key_return_hits": 0, "candidate_rejections": 0, @@ -476,22 +486,56 @@ def _pbkdf_callback(frame, bp_loc, _internal_dict): salt_len = _register(frame, "x4") prf = _register(frame, "x5") rounds = _register(frame, "x6") - if algorithm != 2 or password_len != 32 or salt_len != 16 or prf != 5 or rounds != 256000: + if algorithm != 2 or password_len != 32 or salt_len != 16 or prf != 5 or rounds not in (2, 256000): return False _record_diagnostic("pbkdf_shape_hits") + _record_diagnostic("pbkdf_rounds_2_hits" if rounds == 2 else "pbkdf_rounds_256000_hits") error = lldb.SBError() salt = process.ReadMemory(salt_ptr, salt_len, error) - if not error.Success() or len(salt) != 16 or salt.hex() not in EXPECTED_SALTS: + if not error.Success() or len(salt) != 16: + return False + salt_hex = salt.hex() + if rounds == 2: + database_salt = EXPECTED_HMAC_SALTS.get(salt_hex, "") + source = "pbkdf2_hmac_password" + else: + database_salt = salt_hex if salt_hex in EXPECTED_SALTS else "" + source = "pbkdf2_passphrase" + if not database_salt: return False _record_diagnostic("pbkdf_salt_hits") password = process.ReadMemory(password_ptr, password_len, error) if not error.Success() or len(password) != 32: return False - _save_valid_candidate(password, salt.hex(), "pbkdf2", process) + _save_valid_candidate(password, database_salt, source, process) return False +def _report_process_exit(debugger, _command, _result, _internal_dict): + process = debugger.GetSelectedTarget().GetProcess() + state = process.GetState() + try: + state_name = lldb.SBDebugger.StateAsCString(state) or str(int(state)) + except Exception: + state_name = str(int(state)) + try: + exit_status = int(process.GetExitStatus()) + except Exception: + exit_status = -1 + try: + exit_description = " ".join(str(process.GetExitDescription() or "").split())[:240] + except Exception: + exit_description = "" + payload = {{ + "pid": int(process.GetProcessID() or 0), + "state": state_name, + "exit_status": exit_status, + "exit_description": exit_description, + }} + _write_result({{"diagnostics": dict(DIAGNOSTICS), "process_exit": payload}}) + print("WEDATA_DEBUG_PROCESS_EXIT " + json.dumps(payload, sort_keys=True), flush=True) + def _read_libcxx_string(frame): process = frame.GetThread().GetProcess() object_address = _register(frame, "x29") - 0xB8 @@ -552,6 +596,7 @@ def _setup(debugger, _command, _result, _internal_dict): def __lldb_init_module(debugger, _internal_dict): debugger.HandleCommand(f"command script add -f {{MODULE_NAME}}._setup wedata_capture") + debugger.HandleCommand(f"command script add -f {{MODULE_NAME}}._report_process_exit wedata_capture_exit_report") ''' @@ -757,6 +802,7 @@ def capture_salt_matched_passphrase( f"command script import {callback_path}\n" "wedata_capture\n" "process continue\n" + "wedata_capture_exit_report\n" "quit\n", encoding="utf-8", ) @@ -785,14 +831,33 @@ def capture_salt_matched_passphrase( ) pbkdf_calls = int(diagnostics.get("pbkdf_calls") or 0) pbkdf_shape_hits = int(diagnostics.get("pbkdf_shape_hits") or 0) + pbkdf_rounds_2_hits = int(diagnostics.get("pbkdf_rounds_2_hits") or 0) + pbkdf_rounds_256000_hits = int(diagnostics.get("pbkdf_rounds_256000_hits") or 0) pbkdf_salt_hits = int(diagnostics.get("pbkdf_salt_hits") or 0) key_return_hits = int(diagnostics.get("key_return_hits") or 0) candidate_rejections = int(diagnostics.get("candidate_rejections") or 0) detail = ( f"断点统计:PBKDF2 调用 {pbkdf_calls},参数匹配 {pbkdf_shape_hits}," + f"rounds=2 命中 {pbkdf_rounds_2_hits},rounds=256000 命中 {pbkdf_rounds_256000_hits}," f"数据库 salt 匹配 {pbkdf_salt_hits},微信内部断点 {key_return_hits}," f"候选校验失败 {candidate_rejections}。" ) + process_exit = payload.get("process_exit") if isinstance(payload.get("process_exit"), dict) else None + if process_exit is not None: + exit_pid = int(process_exit.get("pid") or pid) + exit_state = " ".join(str(process_exit.get("state") or "unknown").split())[:40] + exit_status = int(process_exit.get("exit_status") or 0) + exit_description = " ".join(str(process_exit.get("exit_description") or "").split())[:240] + exit_detail = f"PID {exit_pid},状态 {exit_state},退出码 {exit_status}" + if exit_description: + exit_detail += f",原因 {exit_description}" + raise MacOSDBKeyCaptureFailure( + "debug_wechat_exited_during_capture", + f"独立调试微信在捕获阶段提前结束({exit_detail})。" + detail + + "未保存任何未经数据库校验的候选;" + + "请将这段非敏感诊断随微信版本和 build 一并反馈。", + process_attached=True, + ) raise MacOSDBKeyCaptureFailure( "passphrase_not_captured", "没有捕获到与微信数据库 salt 匹配的 passphrase。" + detail diff --git a/tests/test_macos_clone_capture.py b/tests/test_macos_clone_capture.py index 4f756508..e6e94347 100644 --- a/tests/test_macos_clone_capture.py +++ b/tests/test_macos_clone_capture.py @@ -1,10 +1,13 @@ import ast import ctypes import errno +import hashlib +import hmac import json import os import sys import tempfile +import types import unittest from pathlib import Path from unittest.mock import patch @@ -21,6 +24,7 @@ build_lldb_breakpoint_preflight_script, build_lldb_salt_capture_script, capture_prepared_clone, + capture_salt_matched_passphrase, preflight_capture_breakpoints, preflight_prepared_clone, ) @@ -277,9 +281,12 @@ def test_lldb_callback_requires_database_specific_pbkdf2_call(self) -> None: self.assertIn("password_len != 32", script) self.assertIn("salt_len != 16", script) self.assertIn("prf != 5", script) - self.assertIn("rounds != 256000", script) + self.assertIn("rounds not in (2, 256000)", script) + self.assertIn("EXPECTED_HMAC_SALTS", script) + self.assertIn('source = "pbkdf2_hmac_password"', script) + self.assertIn('source = "pbkdf2_passphrase"', script) self.assertIn(salt, script) - self.assertIn("salt.hex() not in EXPECTED_SALTS", script) + self.assertIn("database_salt = EXPECTED_HMAC_SALTS.get", script) self.assertIn("os.fsync", script) self.assertNotIn('print(password.hex()', script) self.assertLess(script.index("salt = process.ReadMemory"), script.index("password = process.ReadMemory")) @@ -290,6 +297,129 @@ def test_lldb_callback_requires_database_specific_pbkdf2_call(self) -> None: self.assertIn("os._exit(24)", script) self.assertIn("lldb.ePermissionsExecutable", script) self.assertIn("lldb.LLDB_INVALID_ADDRESS", script) + self.assertIn("WEDATA_DEBUG_PROCESS_EXIT", script) + self.assertIn("process.GetExitStatus()", script) + self.assertIn("process.GetExitDescription()", script) + + def test_rounds_two_hmac_profile_yields_only_database_verified_master_key(self) -> None: + salt = bytes(range(16)) + passphrase = bytes(range(32)) + encryption_key = hashlib.pbkdf2_hmac("sha512", passphrase, salt, 256000, dklen=32) + hmac_salt = bytes(value ^ 0x3A for value in salt) + hmac_key = hashlib.pbkdf2_hmac("sha512", encryption_key, hmac_salt, 2, dklen=32) + page = bytearray(4096) + page[:16] = salt + page[16:4032] = bytes([0x5A]) * (4032 - 16) + digest = hmac.new(hmac_key, digestmod=hashlib.sha512) + digest.update(page[16:4032]) + digest.update((1).to_bytes(4, "little")) + page[4032:4096] = digest.digest() + script = build_lldb_salt_capture_script( + Path("/tmp/result.json"), + [salt], + probe_page1=bytes(page), + ) + + class FakeError: + def Success(self) -> bool: + return True + + fake_lldb = types.SimpleNamespace(SBError=FakeError) + namespace = {"__name__": "test_wedata_capture"} + with patch.dict(sys.modules, {"lldb": fake_lldb}): + exec(compile(script, "", "exec"), namespace) + + self.assertTrue(namespace["_candidate_matches_page1"](encryption_key)) + self.assertFalse(namespace["_candidate_matches_page1"](b"x" * 32)) + + class FakeProcess: + def ReadMemory(self, address, length, _error): + values = {0x1000: encryption_key, 0x2000: hmac_salt} + return values[address][:length] + + process = FakeProcess() + registers = {"x0": 2, "x1": 0x1000, "x2": 32, "x3": 0x2000, "x4": 16, "x5": 5, "x6": 2} + + class FakeFrame: + def GetThread(self): + return types.SimpleNamespace(GetProcess=lambda: process) + + def FindRegister(self, name): + return types.SimpleNamespace(GetValueAsUnsigned=lambda: registers[name]) + + captured = [] + namespace["_record_diagnostic"] = lambda _name: None + namespace["_save_valid_candidate"] = lambda candidate, database_salt, source, _process: captured.append( + (candidate, database_salt, source) + ) + namespace["_pbkdf_callback"](FakeFrame(), None, None) + + self.assertEqual(captured, [(encryption_key, salt.hex(), "pbkdf2_hmac_password")]) + registers["x6"] = 3 + namespace["_pbkdf_callback"](FakeFrame(), None, None) + self.assertEqual(len(captured), 1) + + def test_capture_reports_debug_process_exit_without_waiting_for_generic_timeout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + probe = root / "message_0.db" + probe.write_bytes(bytes(range(256)) * 16) + + class FixedTemporaryDirectory: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return str(root) + + def __exit__(self, exc_type, exc, traceback): + return False + + def report_exit(_command, *, timeout): + self.assertEqual(timeout, 285.0) + (root / "result.json").write_text( + json.dumps( + { + "diagnostics": { + "pbkdf_calls": 18, + "pbkdf_shape_hits": 4, + "pbkdf_rounds_2_hits": 4, + }, + "process_exit": { + "pid": 321, + "state": "exited", + "exit_status": 0, + "exit_description": "", + }, + } + ), + encoding="utf-8", + ) + return 'WEDATA_DEBUG_PROCESS_EXIT {"pid": 321, "state": "exited"}' + + with ( + patch("wechat_decrypt_tool.macos_clone_capture.platform.machine", return_value="arm64"), + patch("wechat_decrypt_tool.macos_clone_capture.shutil.which", return_value="/usr/bin/lldb"), + patch( + "wechat_decrypt_tool.macos_clone_capture.tempfile.TemporaryDirectory", + FixedTemporaryDirectory, + ), + patch( + "wechat_decrypt_tool.macos_clone_capture._run_as_administrator", + side_effect=report_exit, + ), + ): + with self.assertRaises(MacOSDBKeyCaptureFailure) as context: + capture_salt_matched_passphrase( + pid=321, + expected_salts=[bytes(range(16))], + probe_db_path=probe, + ) + + self.assertEqual(context.exception.code, "debug_wechat_exited_during_capture") + self.assertIn("PID 321", str(context.exception)) + self.assertIn("rounds=2 命中 4", str(context.exception)) + self.assertIn("未保存任何未经数据库校验的候选", str(context.exception)) def test_salt_normalization_rejects_non_database_values(self) -> None: self.assertEqual(_normalize_salts(["AB" * 16, bytes.fromhex("cd" * 16), "bad"]), ["ab" * 16, "cd" * 16]) From 24ca099b39a3fca17ec8fb6ad68ca65df04cb197 Mon Sep 17 00:00:00 2001 From: coderscsy Date: Sun, 23 Aug 2026 04:48:31 +0800 Subject: [PATCH 3/4] feat(macos): wire recoverable key fallback into UI --- docs/macos-wcdb-key-capture.md | 16 +- frontend/composables/useApi.js | 38 ++ frontend/pages/decrypt.vue | 274 ++++++++++++- .../macos_db_key_capture.py | 2 + .../macos_inplace_capture.py | 32 +- src/wechat_decrypt_tool/platform_support.py | 8 + src/wechat_decrypt_tool/routers/keys.py | 367 +++++++++++++++++- tests/test_decrypt_image_keys_frontend.py | 29 +- tests/test_macos_inplace_capture.py | 41 ++ tests/test_macos_platform_support.py | 169 ++++++++ 10 files changed, 947 insertions(+), 29 deletions(-) diff --git a/docs/macos-wcdb-key-capture.md b/docs/macos-wcdb-key-capture.md index 6040ac9e..cc70e0ff 100644 --- a/docs/macos-wcdb-key-capture.md +++ b/docs/macos-wcdb-key-capture.md @@ -4,7 +4,7 @@ ## 本 Draft 的边界 -本次提交只增加可审查的 Python 后端状态机、数据库验真、恢复逻辑、测试和发布审计脚本。它暂不替换现有的 macOS 受限原生 helper,也不把 LLDB 路径接成默认 UI 流程。维护者确认接口和恢复边界后,再单独提交 API/UI 接线,避免一次 PR 同时改变两套获取方式。 +现有 macOS 受限原生 helper 仍是默认且优先的获取方式。只有 helper 返回经过允许的运行时、版本或进程附加失败,并且用户在前端再次阅读风险说明后,才会展示实验性 LLDB 兜底;组件完整性、签名或信任校验失败不会触发兜底。LLDB 不会静默运行,也不会替换默认路径。 核心文件: @@ -12,6 +12,10 @@ - `macos_clone_capture.py`:APFS 写时复制隔离方案、断点预检、salt 匹配和兼容 UUID 表; - `macos_inplace_capture.py`:可恢复的原路径临时重签事务和重启恢复; - `macos_db_key_discovery.py`:只接受能通过所选数据库首页 HMAC 校验的本地候选。 +- `routers/keys.py`:提供 status、prepare、preflight、capture、cancel 五个分阶段 API,恢复目录由后端固定,前端不能传入任意恢复路径; +- `frontend/pages/decrypt.vue`:helper 失败后的显式风险确认、分阶段操作提示、停止/切页恢复和遗留状态恢复入口。 + +上述 LLDB API 强制只接受运行 WCDA 的 Mac 本机回环连接;即使聊天查看或总结开启了局域网/Tailscale 访问,远端设备也不能触发临时重签、调试或恢复操作。 ## 安全状态机 @@ -21,9 +25,9 @@ 4. 只对一次捕获所需的临时实例进行调试签名。先登录进入聊天页,再短暂附加做断点预检并立即分离。 5. 用户在未监测状态退出账号;正式监测开始后重新登录同一账号。 6. 通用路径只接受两种经过验证的 WCDB 参数形状:`rounds=256000` 时 salt 必须等于目标数据库 salt;`rounds=2` 时 salt 必须等于目标数据库 salt 逐字节异或 `0x3A` 后的 HMAC salt。其他轮数一律拒绝。已知版本还可使用按模块 UUID 明确登记的内部返回点,未知 UUID 不猜偏移。 -7. 32 字节候选必须通过目标数据库首页 HMAC 校验才会保存,缓存目录和文件分别使用 `0700`、`0600`。 +7. 32 字节候选先通过目标数据库首页 HMAC 校验;API 还会校验同一账号的消息库与会话库,全部通过后才写入缓存。缓存目录和文件分别使用 `0700`、`0600`。 8. LLDB 在 `process continue` 返回后记录隔离微信的 PID、进程状态、退出码和不超过 240 字符的退出原因;副本提前退出时立即返回专用诊断,不继续等待为普通超时。 -9. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。 +9. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。若桌面应用被强制结束,重新进入解密页时会先检测遗留状态,并由用户明确确认恢复。 ## 源码复现 @@ -37,10 +41,12 @@ python -m pytest -q \ tests/test_macos_clone_capture.py \ tests/test_macos_inplace_capture.py \ tests/test_macos_db_key_discovery.py \ - tests/test_macos_key_capture_release_audit.py + tests/test_macos_key_capture_release_audit.py \ + tests/test_macos_platform_support.py \ + tests/test_decrypt_image_keys_frontend.py ``` -真实端到端验证采用显式的三阶段调用,便于 UI 在每一步向用户确认: +真实端到端验证可采用下列显式三阶段调用;桌面前端使用等价的五个分阶段 API,并在每一步向用户确认: ```python from pathlib import Path diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index da4c371e..cfd51a1a 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -656,6 +656,39 @@ export const useApi = () => { return await request(url, params?.signal ? { signal: params.signal } : {}) } + const getMacosKeyCaptureStatus = async () => { + return await request('/macos-key-capture/status') + } + + const macosKeyCaptureRequest = async (action, params = {}) => { + const options = { + method: 'POST', + body: { + wechat_install_path: params.wechat_install_path || null, + db_storage_path: params.db_storage_path || null, + timeout: params.timeout || 240 + } + } + if (params.signal) options.signal = params.signal + return await request(`/macos-key-capture/${action}`, options) + } + + const prepareMacosKeyCapture = async (params = {}) => { + return await macosKeyCaptureRequest('prepare', params) + } + + const preflightMacosKeyCapture = async (params = {}) => { + return await macosKeyCaptureRequest('preflight', params) + } + + const captureMacosKey = async (params = {}) => { + return await macosKeyCaptureRequest('capture', params) + } + + const cancelMacosKeyCapture = async (params = {}) => { + return await macosKeyCaptureRequest('cancel', params) + } + // 获取图片密钥 const getImageKey = async (params = {}) => { const query = new URLSearchParams() @@ -884,6 +917,11 @@ export const useApi = () => { getWrappedAnnualMeta, getWrappedAnnualCard, getKeys, + getMacosKeyCaptureStatus, + prepareMacosKeyCapture, + preflightMacosKeyCapture, + captureMacosKey, + cancelMacosKeyCapture, getImageKey, getImageKeyMemory, getWxStatus, diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 16270818..8ed8bff2 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -29,8 +29,8 @@ 解密密钥 * -
-
+
+
{{ !platformCapabilitiesLoaded ? '正在检测系统' : (isGettingDbKey ? '获取中...' : '一键获取数据库密钥') }} +

@@ -73,7 +81,7 @@ {{ isMacos - ? '点击后将调用本地受控组件;显示“获取中”后,请完整退出微信程序,再立即重新打开并登录。WCDA 会自动跟随重启后的微信进程,密钥不会上传。' + ? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性 LLDB 兜底。密钥只在本机校验,不会上传。' : '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}

@@ -822,7 +830,21 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { useApi } from '~/composables/useApi' import { normalizeWechatInstallPath, readStoredWechatInstallPath } from '~/lib/wechat-install-path' -const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getImageKeyMemory, getWxStatus, getPlatformCapabilities } = useApi() +const { + decryptDatabase, + saveMediaKeys, + getSavedKeys, + getKeys, + getMacosKeyCaptureStatus, + prepareMacosKeyCapture, + preflightMacosKeyCapture, + captureMacosKey, + cancelMacosKeyCapture, + getImageKey, + getImageKeyMemory, + getWxStatus, + getPlatformCapabilities +} = useApi() const loading = ref(false) const error = ref('') @@ -834,6 +856,9 @@ const activeKeyAccount = ref('') const isGettingDbKey = ref(false) let dbKeyRequestRevision = 0 let dbKeyRequestController = null +const macosKeyCapturePrepared = ref(false) +const macosKeyCaptureOwnedByPage = ref(false) +const macosKeyCaptureCleanupInFlight = ref(false) const platformCapabilities = ref({ platform: '' }) const platformCapabilitiesLoaded = ref(false) const isMacos = computed(() => platformCapabilities.value?.platform === 'macos') @@ -1370,14 +1395,42 @@ const waitForDbKeyDelay = (milliseconds, signal) => new Promise((resolve, reject signal.addEventListener('abort', onAbort, { once: true }) }) +const macosKeyCapturePayload = () => ({ + wechat_install_path: String(formData.wechat_install_path || '').trim() || null, + db_storage_path: String(formData.db_storage_path || '').trim() || null, + timeout: 240 +}) + +const cleanupMacosKeyCapture = async ({ silent = false } = {}) => { + if (!isMacos.value || macosKeyCaptureCleanupInFlight.value) return false + macosKeyCaptureCleanupInFlight.value = true + try { + const response = await cancelMacosKeyCapture(macosKeyCapturePayload()) + if (response?.status === 0) { + macosKeyCapturePrepared.value = false + macosKeyCaptureOwnedByPage.value = false + if (!silent) warning.value = '实验性密钥获取已停止,并已校验恢复腾讯官方签名微信。' + return true + } + if (!silent) error.value = response?.errmsg || '停止实验性密钥获取失败,请不要启动微信并查看日志。' + return false + } catch (cleanupError) { + if (!silent) error.value = cleanupError?.message || '停止实验性密钥获取失败,请不要启动微信并查看日志。' + return false + } finally { + macosKeyCaptureCleanupInFlight.value = false + } +} + const cancelDbKeyAcquisition = () => { - if (!dbKeyRequestController && !isGettingDbKey.value) return + if (!dbKeyRequestController && !isGettingDbKey.value && !macosKeyCaptureOwnedByPage.value) return dbKeyRequestRevision += 1 const controller = dbKeyRequestController dbKeyRequestController = null controller?.abort() isGettingDbKey.value = false + if (macosKeyCaptureOwnedByPage.value) void cleanupMacosKeyCapture({ silent: true }) } const showDbKeyPersistenceWarning = (result) => { @@ -1390,15 +1443,146 @@ const showDbKeyPersistenceWarning = (result) => { }) } +const runMacosLldbFallback = async ({ requestRevision, requestController, helperError }) => { + const riskAccepted = await requestGuideDialog({ + eyebrow: '实验性兜底方式', + title: '受控组件失败,是否改用 LLDB 兜底?', + description: '该方式会在管理员授权后临时重签默认路径中的微信,并在成功、失败或停止时恢复已校验的腾讯官方版本。', + errorMessage: helperError ? `受控组件未能获取密钥:${helperError}` : '', + details: [ + '仅支持 /Applications/WeChat.app 与 Apple Silicon Mac', + '操作前会在应用输出目录创建并校验官方微信备份', + '临时重签和调试可能触发微信安全提醒,无法承诺零风险或百分之百成功', + '不要强制退出 WCDA;需要停止时请使用“停止并恢复”或返回操作' + ], + note: '这是显式确认后的实验性兜底,不会在受控组件正常时自动启用。', + primaryLabel: '了解风险,继续', + secondaryLabel: '暂不使用', + tone: 'warning' + }) + if (!isDbKeyRequestActive(requestRevision, requestController) || !riskAccepted) return false + + warning.value = '正在备份并准备临时调试微信,期间可能出现管理员授权窗口。' + let response = await prepareMacosKeyCapture({ + ...macosKeyCapturePayload(), + signal: requestController.signal + }) + if (!isDbKeyRequestActive(requestRevision, requestController)) { + if (response?.status === 0) { + macosKeyCapturePrepared.value = true + macosKeyCaptureOwnedByPage.value = true + await cleanupMacosKeyCapture({ silent: true }) + } + return false + } + if (response?.status !== 0) { + error.value = response?.errmsg || '无法准备临时调试微信。' + warning.value = '' + return false + } + macosKeyCapturePrepared.value = true + macosKeyCaptureOwnedByPage.value = true + + const loggedIn = await requestGuideDialog({ + eyebrow: '步骤 1 / 3', + title: '请先登录临时微信并进入聊天页', + description: '现在只完成登录,不要退出账号。进入任意聊天页面后再继续,系统会先检查断点是否适配当前微信版本。', + details: [ + '如果微信要求手机确认或验证码,请先完整完成', + '确认桌面微信已进入主聊天界面', + '此阶段尚未开始读取登录密钥' + ], + note: '选择停止会立即尝试恢复腾讯官方版本。', + primaryLabel: '已进入聊天,开始预检', + secondaryLabel: '停止并恢复', + tone: 'guide' + }) + if (!isDbKeyRequestActive(requestRevision, requestController) || !loggedIn) { + await cleanupMacosKeyCapture({ silent: !isDbKeyRequestActive(requestRevision, requestController) }) + return false + } + + warning.value = '正在短暂附加 LLDB 检查断点,完成后会立即脱离。' + response = await preflightMacosKeyCapture({ + ...macosKeyCapturePayload(), + signal: requestController.signal + }) + if (!isDbKeyRequestActive(requestRevision, requestController)) { + await cleanupMacosKeyCapture({ silent: true }) + return false + } + if (response?.status !== 0) { + macosKeyCapturePrepared.value = response?.data?.needs_cleanup === true + macosKeyCaptureOwnedByPage.value = macosKeyCapturePrepared.value + error.value = response?.errmsg || '当前微信版本未通过 LLDB 断点预检,已停止并恢复。' + warning.value = '' + if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) + return false + } + + const loggedOut = await requestGuideDialog({ + eyebrow: '步骤 2 / 3', + title: '请在临时微信中退出当前账号', + description: '退出到二维码登录界面后不要重新扫码;回到这里点击开始监测,再用手机确认登录。', + details: [ + '必须使用微信菜单中的“退出登录”,不要关闭微信窗口', + '看到二维码后先回到 WCDA 点击“开始监测”', + '监测启动后再扫码或在手机上确认同一账号登录' + ], + note: '捕获只针对这次重新登录计算,并使用所选账号数据库实时校验结果。', + primaryLabel: '已看到二维码,开始监测', + secondaryLabel: '停止并恢复', + tone: 'warning' + }) + if (!isDbKeyRequestActive(requestRevision, requestController) || !loggedOut) { + await cleanupMacosKeyCapture({ silent: !isDbKeyRequestActive(requestRevision, requestController) }) + return false + } + + warning.value = '监测已启动:现在请扫码或在手机上确认登录,完成前不要关闭微信或 WCDA。' + response = await captureMacosKey({ + ...macosKeyCapturePayload(), + signal: requestController.signal + }) + if (!isDbKeyRequestActive(requestRevision, requestController)) { + await cleanupMacosKeyCapture({ silent: true }) + return false + } + macosKeyCapturePrepared.value = response?.data?.needs_cleanup === true + macosKeyCaptureOwnedByPage.value = macosKeyCapturePrepared.value + const key = String(response?.data?.db_key || '').trim().toLowerCase() + if (response?.status === 0 && /^[0-9a-f]{64}$/.test(key)) { + macosKeyCapturePrepared.value = false + macosKeyCaptureOwnedByPage.value = false + formData.key = key + warning.value = response?.data?.official_wechat_verified + ? '数据库密钥已通过完整数据库校验,腾讯官方签名微信已恢复。' + : '数据库密钥已获取并通过完整数据库校验。' + return true + } + error.value = response?.errmsg || '实验性 LLDB 未能获取可验证的数据库密钥。' + warning.value = '' + if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) + return false +} + const handleGetDbKey = async () => { if (isGettingDbKey.value) return if (isMacos.value) { + if (macosKeyCapturePrepared.value && !macosKeyCaptureOwnedByPage.value) { + await recoverPendingMacosKeyCapture() + if (macosKeyCapturePrepared.value) return + } formData.key = '' formErrors.key = '' - if (platformCapabilities.value?.database_key_extraction !== true) { - error.value = platformCapabilities.value?.database_key_guidance || 'macOS 数据库密钥组件不可用,请更新或重新安装正式版本。' + const helperAvailable = platformCapabilities.value?.database_key_extraction === true + const lldbFallbackAvailable = platformCapabilities.value?.macos_lldb_fallback === true + if (!helperAvailable && !lldbFallbackAvailable) { + error.value = platformCapabilities.value?.database_key_guidance + || platformCapabilities.value?.macos_lldb_fallback_note + || '当前 Mac 没有可用的数据库密钥获取方式。' return } @@ -1407,14 +1591,22 @@ const handleGetDbKey = async () => { dbKeyRequestController = requestController isGettingDbKey.value = true error.value = '' - warning.value = '捕获已开始:现在请完整退出微信程序,再立即重新打开并登录;WCDA 会自动挂接重启后的微信进程,请勿关闭 WCDA 或当前页面。' + warning.value = helperAvailable + ? '受控组件捕获已开始:现在请完整退出微信程序,再立即重新打开并登录;请勿关闭 WCDA 或当前页面。' + : '' try { - const res = await getKeys({ - db_storage_path: String(formData.db_storage_path || '').trim(), - key_mode: 'macos_private_helper', - signal: requestController.signal - }) + const res = helperAvailable + ? await getKeys({ + db_storage_path: String(formData.db_storage_path || '').trim(), + key_mode: 'macos_private_helper', + signal: requestController.signal + }) + : { + status: -1, + errmsg: platformCapabilities.value?.database_key_guidance || 'macOS 受控组件当前不可用。', + data: { can_fallback_to_macos_lldb: lldbFallbackAvailable } + } if (!isDbKeyRequestActive(requestRevision, requestController)) return const key = String(res?.data?.db_key || '').trim().toLowerCase() if (res?.status === 0 && /^[0-9a-f]{64}$/.test(key)) { @@ -1426,12 +1618,26 @@ const handleGetDbKey = async () => { && warning.value.includes('获取成功') ) warning.value = '' }, 3000) + } else if ( + lldbFallbackAvailable + && (res?.data?.can_fallback_to_macos_lldb === true || !helperAvailable) + ) { + warning.value = '' + await runMacosLldbFallback({ + requestRevision, + requestController, + helperError: res?.errmsg || '受控组件当前不可用' + }) } else { error.value = res?.errmsg || 'macOS 数据库密钥获取失败,请重新点击获取并按提示退出、重启微信。' warning.value = '' } } catch (e) { - if (!isDbKeyRequestActive(requestRevision, requestController) || e?.name === 'AbortError') return + if (!isDbKeyRequestActive(requestRevision, requestController) || e?.name === 'AbortError') { + if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) + return + } + if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) error.value = e?.message || 'macOS 数据库密钥获取失败,请稍后重试。' warning.value = '' } finally { @@ -2272,8 +2478,12 @@ const confirmBackFromRunningStep = () => { : currentStep.value === 0 && isGettingDbKey.value ? { title: '数据库密钥仍在获取', - description: '返回账号选择会停止当前页面等待结果;如果 Hook 已经开始,微信重启或登录流程仍可能继续完成。', - details: ['页面将不再接收本次密钥结果', '已经启动的 Hook 操作无法保证立即停止'] + description: isMacos.value + ? '返回账号选择会停止当前获取;如果已进入 LLDB 兜底,系统会同时尝试恢复腾讯官方签名微信。' + : '返回账号选择会停止当前页面等待结果;如果 Hook 已经开始,微信重启或登录流程仍可能继续完成。', + details: isMacos.value + ? ['页面将不再接收本次密钥结果', '恢复完成前请不要手动启动或更新微信'] + : ['页面将不再接收本次密钥结果', '已经启动的 Hook 操作无法保证立即停止'] } : currentStep.value === 2 && mediaDecrypting.value ? { title: '图片仍在解密', description: '返回填写图片密钥会停止当前图片解密,已经完成的图片会保留。' } @@ -2425,6 +2635,37 @@ const skipToChat = async () => { navigateTo('/chat') } +const recoverPendingMacosKeyCapture = async () => { + if (!isMacos.value) return + try { + const response = await getMacosKeyCaptureStatus() + if (response?.status !== 0 || response?.data?.pending !== true) return + macosKeyCapturePrepared.value = true + macosKeyCaptureOwnedByPage.value = false + const shouldRestore = await requestGuideDialog({ + eyebrow: '安全恢复', + title: '检测到上次未完成的临时调试微信', + description: '恢复状态仍在本机。建议先校验并恢复腾讯官方签名微信,再开始新的密钥获取。', + details: [ + `上次停止阶段:${String(response?.data?.stage || 'unknown')}`, + '恢复只使用上次已记录并校验的备份事务', + '恢复完成前不要启动或更新微信' + ], + note: 'WCDA 不会静默覆盖微信,需要您明确确认恢复。', + primaryLabel: '立即恢复官方版本', + secondaryLabel: '暂不处理', + tone: 'warning' + }) + if (shouldRestore) { + await cleanupMacosKeyCapture() + } else { + warning.value = '仍有未完成的临时调试微信恢复状态;恢复前不会开始新的 LLDB 获取。' + } + } catch (statusError) { + logDecryptDebug('macos-key-capture:status-error', { error: formatLogError(statusError) }) + } +} + // 页面加载时检查是否有选中的账户 onMounted(async () => { if (process.client && typeof window !== 'undefined') { @@ -2446,6 +2687,7 @@ onMounted(async () => { } finally { platformCapabilitiesLoaded.value = true } + await recoverPendingMacosKeyCapture() formData.wechat_install_path = readStoredWechatInstallPath() const selectedAccount = sessionStorage.getItem('selectedAccount') logDecryptDebug('mounted:selected-account-raw', { raw: selectedAccount || '' }) diff --git a/src/wechat_decrypt_tool/macos_db_key_capture.py b/src/wechat_decrypt_tool/macos_db_key_capture.py index c35b0bf7..d1a81b81 100644 --- a/src/wechat_decrypt_tool/macos_db_key_capture.py +++ b/src/wechat_decrypt_tool/macos_db_key_capture.py @@ -1097,6 +1097,7 @@ def capture_prepared_macos_passphrase( backup_root: Path, probe_db_path: str | Path | None = None, timeout: int = 240, + save_result: bool = True, ) -> dict[str, Any]: """Attach after logout, capture on re-login, then restore the official app.""" @@ -1107,6 +1108,7 @@ def capture_prepared_macos_passphrase( backup_root=backup_root, probe_db_path=probe_db_path, timeout=timeout, + save_result=save_result, ) diff --git a/src/wechat_decrypt_tool/macos_inplace_capture.py b/src/wechat_decrypt_tool/macos_inplace_capture.py index 30679c06..ae0087f3 100644 --- a/src/wechat_decrypt_tool/macos_inplace_capture.py +++ b/src/wechat_decrypt_tool/macos_inplace_capture.py @@ -50,6 +50,29 @@ def has_pending_in_place_capture(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> bo return _state_path(debug_root).is_file() +def get_in_place_capture_status(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> dict[str, Any]: + """Return the persisted recovery stage without exposing local paths.""" + + if not has_pending_in_place_capture(debug_root=debug_root): + return { + "pending": False, + "stage": "idle", + "needs_cleanup": False, + } + try: + state = _read_state(debug_root) + stage = str(state.get("stage") or "unknown").strip().lower() + except MacOSDBKeyCaptureFailure: + stage = "invalid" + if stage not in {"backup_verified", "resigned", "launched", "preflight_passed", "invalid"}: + stage = "unknown" + return { + "pending": True, + "stage": stage, + "needs_cleanup": True, + } + + def _write_state(debug_root: Path, payload: dict[str, Any]) -> Path: target = _state_path(debug_root) target.parent.mkdir(parents=True, exist_ok=True) @@ -398,6 +421,7 @@ def capture_prepared_in_place( backup_root: Path, probe_db_path: str | Path | None, timeout: int = 240, + save_result: bool = True, debug_root: Path = DEFAULT_DEBUG_ROOT, ) -> dict[str, Any]: wechat_app = normalize_wechat_app_path(wechat_install_path) @@ -449,7 +473,8 @@ def capture_prepared_in_place( enable_key_return_fallback=int(preflight.get("key_return_locations") or 0) > 0, ) _validate_captured_passphrase(passphrase, probe_database) - cache_path = save_passphrase(passphrase) + if save_result: + cache_path = save_passphrase(passphrase) except Exception as exc: if has_pending_in_place_capture(debug_root=debug_root): _restore_after_terminal_path( @@ -466,12 +491,12 @@ def capture_prepared_in_place( debug_root=debug_root, ) - if cache_path is None: + if save_result and cache_path is None: raise MacOSDBKeyCaptureFailure("passphrase_not_saved", "passphrase 未能安全保存") return { "method": "macos_inplace_lldb_passphrase", "db_key": passphrase, - "cache_path": str(cache_path), + "cache_path": str(cache_path) if cache_path is not None else "", "wechat_modified": False, "wechat_resigned": True, "official_wechat_preserved": True, @@ -490,6 +515,7 @@ def capture_prepared_in_place( __all__ = [ "capture_prepared_in_place", "cleanup_in_place_capture", + "get_in_place_capture_status", "has_pending_in_place_capture", "preflight_prepared_in_place_capture", "prepare_in_place_capture", diff --git a/src/wechat_decrypt_tool/platform_support.py b/src/wechat_decrypt_tool/platform_support.py index 76f69b91..a7522ee2 100644 --- a/src/wechat_decrypt_tool/platform_support.py +++ b/src/wechat_decrypt_tool/platform_support.py @@ -3,6 +3,7 @@ import json import os import platform +import shutil import sys from pathlib import Path from typing import Any @@ -185,6 +186,7 @@ def runtime_capabilities() -> dict[str, Any]: and native_core_paths and _native_core_resources_ready(native_core_paths) ) + macos_lldb_fallback = bool(apple_silicon and shutil.which("lldb")) mac_db_key_status: dict[str, Any] = { "available": False, "note": MAC_DB_KEY_GUIDANCE, @@ -205,6 +207,12 @@ def runtime_capabilities() -> dict[str, Any]: "architecture": architecture, "apple_silicon": apple_silicon, "database_key_extraction": system == "windows" or bool(mac_db_key_status["available"]), + "macos_lldb_fallback": macos_lldb_fallback, + "macos_lldb_fallback_note": ( + "实验性 LLDB 兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。" + if system == "macos" and not macos_lldb_fallback + else "" + ), "database_key_manual_input": True, "database_decryption": True, "image_key_memory_scan": system == "windows" or image_scan_ready, diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py index 73635ec4..ba960833 100644 --- a/src/wechat_decrypt_tool/routers/keys.py +++ b/src/wechat_decrypt_tool/routers/keys.py @@ -1,4 +1,5 @@ import asyncio +import ipaddress import threading from pathlib import Path from typing import Optional @@ -7,20 +8,170 @@ from pydantic import BaseModel, Field from ..logging_config import get_logger +from ..app_paths import get_output_dir +from ..macos_db_key_capture import ( + MacOSDBKeyCaptureFailure, + capture_prepared_macos_passphrase, + cleanup_macos_passphrase_capture, + preflight_prepared_macos_passphrase, + prepare_macos_passphrase_capture, + save_passphrase, +) from ..macos_db_key_helper import MacosDbKeyError +from ..macos_inplace_capture import get_in_place_capture_status from ..key_store import get_account_keys_from_store, normalize_key_store_path from ..key_service import ( + _resolve_v4_probe_db_file, get_db_key_workflow, get_image_key_integrated_workflow, get_image_key_memory_workflow, ) from ..media_helpers import _load_media_keys, _resolve_account_dir from ..path_fix import PathFixRoute -from ..platform_support import current_platform, is_macos +from ..platform_support import current_platform, is_macos, runtime_capabilities router = APIRouter(route_class=PathFixRoute) logger = get_logger(__name__) +_MACOS_LLDB_FALLBACK_CODES = frozenset( + { + "BUILD_EXPIRED", + "CAPTURE_ATTACH_NOT_SUPPORTED", + "CAPTURE_ATTACH_RUNTIME_ERROR", + "CAPTURE_FAILED", + "CAPTURE_KEY_MISMATCH", + "CAPTURE_RUNTIME_UNAVAILABLE", + "CAPTURE_SESSION_DETACHED", + "CONTRACT_MISMATCH", + "DEVELOPMENT_BUILD_REJECTED", + "HELPER_DEBUGGER_ENTITLEMENT_MISSING", + "HELPER_EXITED", + "HELPER_START_FAILED", + "MISSING_RESOURCE", + "PROCESS_ACCESS_DENIED", + "PROCESS_EXITED", + "PROCESS_NOT_FOUND", + "PROTOCOL_ERROR", + "TARGET_PROCESS_PROTECTED", + "TIMEOUT", + "UNSUPPORTED_WECHAT", + "WECHAT_RELOGIN_REQUIRED", + } +) +_macos_capture_state_lock = threading.Lock() +_macos_capture_cancel_lock = threading.Lock() +_macos_capture_active_operation = "" + + +def _macos_capture_backup_root() -> Path: + return get_output_dir() / "macos-key-capture-backups" + + +def _macos_lldb_fallback_available() -> bool: + try: + return bool(runtime_capabilities().get("macos_lldb_fallback")) + except Exception: + return False + + +def _macos_lldb_fallback_allowed(error_code: str) -> bool: + return error_code in _MACOS_LLDB_FALLBACK_CODES and _macos_lldb_fallback_available() + + +def _begin_macos_capture_operation(operation: str) -> bool: + global _macos_capture_active_operation + with _macos_capture_state_lock: + if _macos_capture_active_operation: + return False + _macos_capture_active_operation = operation + return True + + +def _end_macos_capture_operation(operation: str) -> None: + global _macos_capture_active_operation + with _macos_capture_state_lock: + if _macos_capture_active_operation == operation: + _macos_capture_active_operation = "" + + +def _current_macos_capture_operation() -> str: + with _macos_capture_state_lock: + return _macos_capture_active_operation + + +class MacosKeyCaptureRequest(BaseModel): + wechat_install_path: Optional[str] = Field(None, description="微信安装路径") + db_storage_path: Optional[str] = Field(None, description="账号 db_storage 路径") + timeout: int = Field(240, ge=60, le=600, description="等待重新登录的秒数") + + +def _macos_capture_error_response(error: BaseException, *, stage: str) -> dict: + known = isinstance(error, MacOSDBKeyCaptureFailure) + code = str(getattr(error, "code", "INTERNAL_ERROR") or "INTERNAL_ERROR") if known else "INTERNAL_ERROR" + logger.warning( + "[keys] experimental macOS LLDB capture failed: stage=%s error_code=%s classified=%s", + stage, + code, + known, + ) + try: + needs_cleanup = bool(get_in_place_capture_status().get("pending")) + except Exception: + needs_cleanup = bool(getattr(error, "wechat_modified", False)) if known else False + return { + "status": -1, + "errmsg": str(error).strip() if known else "实验性 LLDB 密钥获取失败,已停止本次操作。", + "data": { + "platform": "macos", + "method": "macos_inplace_lldb", + "stage": stage, + "error_code": code, + "wechat_modified": bool(getattr(error, "wechat_modified", False)) if known else False, + "needs_cleanup": needs_cleanup, + }, + } + + +def _macos_capture_busy_response() -> dict: + return { + "status": -1, + "errmsg": "另一项 macOS 密钥操作仍在进行,请等待完成或先停止当前操作。", + "data": { + "platform": "macos", + "method": "macos_inplace_lldb", + "stage": _current_macos_capture_operation() or "busy", + "error_code": "CAPTURE_BUSY", + }, + } + + +def _macos_capture_local_request_error(request: Request) -> dict | None: + client = request.client + host = str(getattr(client, "host", "") or "").strip() + try: + address = ipaddress.ip_address(host) + is_loopback = bool( + address.is_loopback + or ( + isinstance(address, ipaddress.IPv6Address) + and address.ipv4_mapped is not None + and address.ipv4_mapped.is_loopback + ) + ) + except ValueError: + is_loopback = host.lower() == "localhost" + if is_loopback: + return None + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获只能在运行 WCDA 的 Mac 本机界面操作。", + "data": { + "platform": current_platform(), + "method": "macos_inplace_lldb", + "error_code": "REMOTE_CLIENT_FORBIDDEN", + }, + } + def _log_macos_db_key_failure( error: BaseException, @@ -418,6 +569,7 @@ async def watch_disconnect() -> None: "error_code": error_code, "retryable": retryable, "manual_input_supported": True, + "can_fallback_to_macos_lldb": _macos_lldb_fallback_allowed(error_code), }, } mode = str(key_mode or "auto").strip().lower() @@ -457,6 +609,7 @@ async def watch_disconnect() -> None: "error_code": error_code, "retryable": retryable, "manual_input_supported": True, + "can_fallback_to_macos_lldb": _macos_lldb_fallback_allowed(error_code), }, } mode = str(key_mode or "auto").strip().lower() @@ -477,6 +630,218 @@ async def watch_disconnect() -> None: } +@router.get("/api/macos-key-capture/status", summary="查看实验性 macOS LLDB 密钥捕获状态") +async def get_macos_key_capture_status(request: Request): + if local_error := _macos_capture_local_request_error(request): + return local_error + if not is_macos(): + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, + } + persisted = get_in_place_capture_status() + return { + "status": 0, + "errmsg": "ok", + "data": { + **persisted, + "method": "macos_inplace_lldb", + "active_operation": _current_macos_capture_operation(), + }, + } + + +@router.post("/api/macos-key-capture/prepare", summary="准备实验性 macOS LLDB 密钥捕获") +async def prepare_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): + if local_error := _macos_capture_local_request_error(request): + return local_error + if not is_macos(): + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, + } + if not _macos_lldb_fallback_available(): + return { + "status": -1, + "errmsg": "当前 Mac 不满足实验性 LLDB 兜底条件,请确认使用 Apple Silicon 并安装 Xcode Command Line Tools。", + "data": {"platform": "macos", "error_code": "LLDB_FALLBACK_UNAVAILABLE"}, + } + operation = "prepare" + if not _begin_macos_capture_operation(operation): + return _macos_capture_busy_response() + try: + result = await asyncio.to_thread( + prepare_macos_passphrase_capture, + payload.wechat_install_path, + backup_root=_macos_capture_backup_root(), + ) + return { + "status": 0, + "errmsg": "ok", + "data": { + "method": "macos_inplace_lldb", + "stage": "prepared", + "wechat_modified": bool(result.get("wechat_modified", True)), + "ready_for_preflight": bool(result.get("ready_for_preflight", True)), + }, + } + except Exception as error: + return _macos_capture_error_response(error, stage="prepare") + finally: + _end_macos_capture_operation(operation) + + +@router.post("/api/macos-key-capture/preflight", summary="预检实验性 macOS LLDB 断点") +async def preflight_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): + if local_error := _macos_capture_local_request_error(request): + return local_error + if not is_macos(): + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, + } + operation = "preflight" + if not _begin_macos_capture_operation(operation): + return _macos_capture_busy_response() + try: + result = await asyncio.to_thread( + preflight_prepared_macos_passphrase, + payload.wechat_install_path, + backup_root=_macos_capture_backup_root(), + ) + return { + "status": 0, + "errmsg": "ok", + "data": { + "method": "macos_inplace_lldb", + "stage": "preflight_passed", + "wechat_modified": bool(result.get("wechat_modified", True)), + "process_attached": bool(result.get("process_attached", False)), + }, + } + except Exception as error: + return _macos_capture_error_response(error, stage="preflight") + finally: + _end_macos_capture_operation(operation) + + +@router.post("/api/macos-key-capture/capture", summary="执行实验性 macOS LLDB 密钥捕获") +async def capture_macos_key(request: Request, payload: MacosKeyCaptureRequest): + if local_error := _macos_capture_local_request_error(request): + return local_error + if not is_macos(): + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, + } + operation = "capture" + if not _begin_macos_capture_operation(operation): + return _macos_capture_busy_response() + try: + try: + probe_db_path = await asyncio.to_thread( + _resolve_v4_probe_db_file, + payload.db_storage_path, + ) + except Exception as error: + raise MacOSDBKeyCaptureFailure( + "probe_database_invalid", + "所选账号路径中没有可用于实时校验的加密 WCDB 数据库,请重新选择该账号的 db_storage 目录。", + wechat_modified=True, + ) from error + result = await asyncio.to_thread( + capture_prepared_macos_passphrase, + payload.wechat_install_path, + backup_root=_macos_capture_backup_root(), + probe_db_path=probe_db_path, + timeout=payload.timeout, + save_result=False, + ) + db_key = str(result.get("db_key") or "").strip().lower() + from ..wechat_decrypt import validate_realtime_database_key + + validation = await asyncio.to_thread( + validate_realtime_database_key, + str(payload.db_storage_path or ""), + db_key, + ) + if validation.get("valid") is not True: + raise MacOSDBKeyCaptureFailure( + "passphrase_database_mismatch", + "已捕获登录密钥,但未通过当前账号的完整消息与会话数据库校验。请确认选择了同一账号的数据目录。", + ) + await asyncio.to_thread(save_passphrase, db_key) + return { + "status": 0, + "errmsg": "ok", + "data": { + "method": "macos_inplace_lldb", + "stage": "completed", + "db_key": db_key, + "wechat_modified": False, + "official_wechat_verified": bool(result.get("official_wechat_verified")), + "official_wechat_restored": bool(result.get("official_wechat_restored")), + }, + } + except Exception as error: + return _macos_capture_error_response(error, stage="capture") + finally: + _end_macos_capture_operation(operation) + + +@router.post("/api/macos-key-capture/cancel", summary="停止并清理实验性 macOS LLDB 密钥捕获") +async def cancel_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): + if local_error := _macos_capture_local_request_error(request): + return local_error + if not is_macos(): + return { + "status": -1, + "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, + } + if not _macos_capture_cancel_lock.acquire(blocking=False): + return _macos_capture_busy_response() + operation = "cancel" + active_operation = _current_macos_capture_operation() + if active_operation and active_operation != "capture": + _macos_capture_cancel_lock.release() + return _macos_capture_busy_response() + claimed = False + if not active_operation: + claimed = _begin_macos_capture_operation(operation) + if not claimed: + _macos_capture_cancel_lock.release() + return _macos_capture_busy_response() + try: + result = await asyncio.to_thread( + cleanup_macos_passphrase_capture, + payload.wechat_install_path, + backup_root=_macos_capture_backup_root(), + ) + return { + "status": 0, + "errmsg": "ok", + "data": { + "method": "macos_inplace_lldb", + "stage": "cancelled", + "pending": False, + "wechat_modified": False, + "official_wechat_verified": bool(result.get("official_wechat_verified")), + "official_wechat_restored": bool(result.get("official_wechat_restored")), + }, + } + except Exception as error: + return _macos_capture_error_response(error, stage="cancel") + finally: + if claimed: + _end_macos_capture_operation(operation) + _macos_capture_cancel_lock.release() + + @router.get("/api/get_image_key", summary="获取并保存微信图片密钥") async def get_image_key( diff --git a/tests/test_decrypt_image_keys_frontend.py b/tests/test_decrypt_image_keys_frontend.py index a23bd5f4..23dcf54d 100644 --- a/tests/test_decrypt_image_keys_frontend.py +++ b/tests/test_decrypt_image_keys_frontend.py @@ -112,21 +112,42 @@ def test_database_key_action_waits_for_platform_detection(): assert "!platformCapabilitiesLoaded ? '正在检测系统'" in source -def test_macos_database_key_uses_the_bundled_authorized_helper_without_external_guidance(): +def test_macos_database_key_uses_helper_first_and_explicit_lldb_fallback(): source = read_decrypt_page() assert "key_mode: 'macos_private_helper'" in source - assert "platformCapabilities.value?.database_key_extraction !== true" in source - assert "捕获已开始" in source + assert "const helperAvailable = platformCapabilities.value?.database_key_extraction === true" in source + assert "const lldbFallbackAvailable = platformCapabilities.value?.macos_lldb_fallback === true" in source + assert "受控组件捕获已开始" in source assert "完整退出微信程序" in source - assert "自动挂接重启后的微信进程" in source assert "仅退出当前账号" not in source assert "数据库解密密钥已通过 macOS 本地受控组件获取成功" in source + assert "受控组件失败,是否改用 LLDB 兜底" in source + assert "临时重签和调试可能触发微信安全提醒" in source + assert "prepareMacosKeyCapture" in source + assert "preflightMacosKeyCapture" in source + assert "captureMacosKey" in source + assert "cleanupMacosKeyCapture" in source assert "打开 WeFlow 项目页" not in source assert "https://github.com/hicccc77/WeFlow" not in source assert "查看 Mac 获取方式" not in source +def test_macos_lldb_fallback_is_staged_and_recovers_pending_state(): + source = read_decrypt_page() + api_source = read_use_api() + + assert "步骤 1 / 3" in source + assert "已进入聊天,开始预检" in source + assert "步骤 2 / 3" in source + assert "已看到二维码,开始监测" in source + assert "getMacosKeyCaptureStatus" in source + assert "检测到上次未完成的临时调试微信" in source + assert "if (macosKeyCaptureOwnedByPage.value) void cleanupMacosKeyCapture" in source + for action in ("prepare", "preflight", "capture", "cancel"): + assert f"macosKeyCaptureRequest('{action}'" in api_source + + def test_macos_database_key_clears_prefill_and_always_starts_real_capture(): source = read_decrypt_page() diff --git a/tests/test_macos_inplace_capture.py b/tests/test_macos_inplace_capture.py index 81eb2879..5888d195 100644 --- a/tests/test_macos_inplace_capture.py +++ b/tests/test_macos_inplace_capture.py @@ -159,6 +159,47 @@ def test_capture_arms_resolved_internal_return_fallback(self) -> None: self.assertTrue(capture.call_args.kwargs["enable_key_return_fallback"]) + def test_capture_can_defer_cache_until_full_account_validation(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + debug_root = Path(temp_dir) + probe = debug_root / "message_0.db" + probe.write_bytes(bytes(range(256)) * 16) + (debug_root / "breakpoint-preflight.json").write_text( + json.dumps({"pid": 321, "pbkdf_locations": 1, "key_return_locations": 0}), + encoding="utf-8", + ) + with ( + patch( + "wechat_decrypt_tool.macos_inplace_capture.normalize_wechat_app_path", + return_value=Path("/Applications/WeChat.app"), + ), + patch( + "wechat_decrypt_tool.macos_inplace_capture._require_prepared_process", + return_value=({}, 321), + ), + patch( + "wechat_decrypt_tool.macos_inplace_capture.capture_salt_matched_passphrase", + return_value="ab" * 32, + ), + patch("wechat_decrypt_tool.macos_inplace_capture._validate_captured_passphrase"), + patch("wechat_decrypt_tool.macos_inplace_capture.save_passphrase") as save, + patch( + "wechat_decrypt_tool.macos_inplace_capture._restore_after_terminal_path", + return_value={"official_wechat_verified": True, "official_wechat_restored": True}, + ), + ): + result = capture_prepared_in_place( + "/Applications/WeChat.app", + backup_root=debug_root / "backups", + probe_db_path=probe, + save_result=False, + debug_root=debug_root, + ) + + self.assertEqual(result["db_key"], "ab" * 32) + self.assertEqual(result["cache_path"], "") + save.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_macos_platform_support.py b/tests/test_macos_platform_support.py index a3087d13..f84c9c81 100644 --- a/tests/test_macos_platform_support.py +++ b/tests/test_macos_platform_support.py @@ -23,6 +23,10 @@ from wechat_decrypt_tool.routers import keys as keys_router +class LocalRequest: + client = SimpleNamespace(host="127.0.0.1") + + class TestMacosPlatformSupport(unittest.TestCase): def test_packaged_native_resources_prefer_stable_sibling_directory(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -103,6 +107,7 @@ def test_apple_silicon_capabilities_enable_validated_db_key_extraction(self) -> with ( patch.object(platform_support, "current_platform", return_value="macos"), patch.object(platform, "machine", return_value="arm64"), + patch.object(platform_support.shutil, "which", return_value="/usr/bin/lldb"), patch.object( platform_support, "mac_native_core_paths", @@ -123,6 +128,7 @@ def test_apple_silicon_capabilities_enable_validated_db_key_extraction(self) -> capabilities = platform_support.runtime_capabilities() self.assertTrue(capabilities["database_key_extraction"]) + self.assertTrue(capabilities["macos_lldb_fallback"]) self.assertTrue(capabilities["database_key_manual_input"]) self.assertTrue(capabilities["database_decryption"]) self.assertTrue(capabilities["image_key_memory_scan"]) @@ -219,6 +225,169 @@ async def is_disconnected(self) -> bool: self.assertIn("完整退出微信程序", result["errmsg"]) self.assertIn("重新打开微信并登录", result["errmsg"]) + def test_database_key_endpoint_offers_explicit_lldb_fallback_for_runtime_failure(self) -> None: + class ConnectedRequest: + async def is_disconnected(self) -> bool: + return False + + failure = MacosDbKeyUnavailableError( + "protected", + code="TARGET_PROCESS_PROTECTED", + retryable=False, + ) + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object(keys_router, "get_db_key_workflow", side_effect=failure), + patch.object(keys_router, "_macos_lldb_fallback_available", return_value=True), + ): + result = asyncio.run(keys_router.get_wechat_db_key(ConnectedRequest())) + + self.assertEqual(result["status"], -1) + self.assertTrue(result["data"]["can_fallback_to_macos_lldb"]) + + def test_database_key_endpoint_does_not_offer_lldb_after_integrity_failure(self) -> None: + class ConnectedRequest: + async def is_disconnected(self) -> bool: + return False + + failure = MacosDbKeyUnavailableError( + "tampered", + code="HELPER_TAMPERED", + retryable=False, + ) + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object(keys_router, "get_db_key_workflow", side_effect=failure), + patch.object(keys_router, "_macos_lldb_fallback_available", return_value=True), + ): + result = asyncio.run(keys_router.get_wechat_db_key(ConnectedRequest())) + + self.assertFalse(result["data"]["can_fallback_to_macos_lldb"]) + + def test_macos_lldb_prepare_endpoint_returns_only_sanitized_stage(self) -> None: + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object(keys_router, "_macos_lldb_fallback_available", return_value=True), + patch.object( + keys_router, + "prepare_macos_passphrase_capture", + return_value={ + "wechat_modified": True, + "ready_for_preflight": True, + "backup_path": "/private/backup.zip", + "state_path": "/private/state.json", + }, + ), + ): + result = asyncio.run( + keys_router.prepare_macos_key_capture(LocalRequest(), keys_router.MacosKeyCaptureRequest()) + ) + + self.assertEqual(result["status"], 0) + self.assertEqual(result["data"]["stage"], "prepared") + self.assertNotIn("backup_path", result["data"]) + self.assertNotIn("state_path", result["data"]) + + def test_macos_lldb_capture_validates_before_caching_and_returning_key(self) -> None: + db_key = "ab" * 32 + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object(keys_router, "_resolve_v4_probe_db_file", return_value=Path("/tmp/message_0.db")), + patch.object( + keys_router, + "capture_prepared_macos_passphrase", + return_value={ + "db_key": db_key, + "cache_path": "/private/key.json", + "backup_path": "/private/backup.zip", + "official_wechat_verified": True, + "official_wechat_restored": True, + }, + ) as capture, + patch( + "wechat_decrypt_tool.wechat_decrypt.validate_realtime_database_key", + return_value={"valid": True, "verified_roles": ["message", "session"]}, + ), + patch.object(keys_router, "save_passphrase") as save, + ): + result = asyncio.run( + keys_router.capture_macos_key( + LocalRequest(), + keys_router.MacosKeyCaptureRequest(db_storage_path="/tmp/db_storage") + ) + ) + + self.assertEqual(result["status"], 0) + self.assertEqual(result["data"]["db_key"], db_key) + self.assertNotIn("cache_path", result["data"]) + self.assertNotIn("backup_path", result["data"]) + self.assertFalse(capture.call_args.kwargs["save_result"]) + save.assert_called_once_with(db_key) + + def test_macos_lldb_status_and_cancel_do_not_expose_recovery_paths(self) -> None: + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object( + keys_router, + "get_in_place_capture_status", + return_value={"pending": True, "stage": "launched", "needs_cleanup": True}, + ), + ): + status = asyncio.run(keys_router.get_macos_key_capture_status(LocalRequest())) + + self.assertEqual(status["status"], 0) + self.assertTrue(status["data"]["pending"]) + self.assertNotIn("backup_path", status["data"]) + self.assertNotIn("state_path", status["data"]) + + with ( + patch.object(keys_router, "is_macos", return_value=True), + patch.object( + keys_router, + "cleanup_macos_passphrase_capture", + return_value={ + "official_wechat_verified": True, + "official_wechat_restored": True, + "backup_path": "/private/backup.zip", + }, + ), + ): + cancelled = asyncio.run( + keys_router.cancel_macos_key_capture(LocalRequest(), keys_router.MacosKeyCaptureRequest()) + ) + + self.assertEqual(cancelled["status"], 0) + self.assertTrue(cancelled["data"]["official_wechat_verified"]) + self.assertNotIn("backup_path", cancelled["data"]) + + def test_macos_lldb_endpoints_reject_overlapping_mutations(self) -> None: + self.assertTrue(keys_router._begin_macos_capture_operation("prepare")) + try: + with patch.object(keys_router, "is_macos", return_value=True): + result = asyncio.run( + keys_router.capture_macos_key(LocalRequest(), keys_router.MacosKeyCaptureRequest()) + ) + self.assertEqual(result["status"], -1) + self.assertEqual(result["data"]["error_code"], "CAPTURE_BUSY") + finally: + keys_router._end_macos_capture_operation("prepare") + + def test_macos_lldb_endpoints_reject_remote_clients(self) -> None: + remote_request = SimpleNamespace(client=SimpleNamespace(host="100.64.0.8")) + with patch.object(keys_router, "is_macos", return_value=True), patch.object( + keys_router, "prepare_macos_passphrase_capture" + ) as prepare: + result = asyncio.run( + keys_router.prepare_macos_key_capture( + remote_request, + keys_router.MacosKeyCaptureRequest(), + ) + ) + + self.assertEqual(result["status"], -1) + self.assertEqual(result["data"]["error_code"], "REMOTE_CLIENT_FORBIDDEN") + prepare.assert_not_called() + def test_macos_database_key_endpoint_redacts_unknown_internal_errors(self) -> None: class ConnectedRequest: async def is_disconnected(self) -> bool: From 35f61cba9ce184e5f8827f710973e95bd23eb872 Mon Sep 17 00:00:00 2001 From: coderscsy Date: Sun, 23 Aug 2026 09:34:44 +0800 Subject: [PATCH 4/4] fix(macos): stabilize native WCDB key capture --- docs/macos-wcdb-key-capture-validation.md | 4 +- docs/macos-wcdb-key-capture.md | 13 +- frontend/composables/useApi.js | 4 +- frontend/pages/decrypt.vue | 57 +- pyproject.toml | 1 + .../macos_clone_capture.py | 2 + .../macos_inplace_capture.py | 270 ++++- .../macos_native_capture.py | 344 ++++++ .../native/macos/source/wcdb_native_capture.c | 1048 +++++++++++++++++ src/wechat_decrypt_tool/platform_support.py | 7 +- src/wechat_decrypt_tool/routers/keys.py | 46 +- tests/test_decrypt_image_keys_frontend.py | 26 +- tests/test_macos_clone_capture.py | 2 + tests/test_macos_inplace_capture.py | 38 +- tests/test_macos_native_capture_source.py | 33 + tests/test_macos_platform_support.py | 4 +- 16 files changed, 1817 insertions(+), 82 deletions(-) create mode 100644 src/wechat_decrypt_tool/macos_native_capture.py create mode 100644 src/wechat_decrypt_tool/native/macos/source/wcdb_native_capture.c create mode 100644 tests/test_macos_native_capture_source.py diff --git a/docs/macos-wcdb-key-capture-validation.md b/docs/macos-wcdb-key-capture-validation.md index 95927641..10fdaa01 100644 --- a/docs/macos-wcdb-key-capture-validation.md +++ b/docs/macos-wcdb-key-capture-validation.md @@ -29,8 +29,10 @@ | 断点预检失败 | 模拟无 PBKDF2/内部返回断点 | 立即停止,不要求用户退出账号,cleanup 恢复 | | 捕获超时 | 模拟 LLDB 超时且无合格候选 | 不保存候选,`finally` 恢复官方微信 | | 隔离副本提前退出 | 模拟 LLDB 报告进程已退出及退出码 | 立即返回 `debug_wechat_exited_during_capture` 和非敏感断点计数,不继续等待普通超时 | -| 微信 4.1.13 `rounds=2` | 模拟目标库 HMAC salt 与 32 字节主密钥候选 | 只接受精确 HMAC salt 映射且通过目标数据库首页 HMAC 的候选;拒绝其他轮数 | +| WCDB `rounds=2` | 模拟目标库 HMAC salt 与 raw encryption key | 只接受精确 HMAC salt 映射且通过目标页 HMAC 的候选;前端 API 还要求通过 message + session 双角色校验后才缓存 | | 数据库不匹配 | 使用无法通过目标首页 HMAC 的 32 字节候选 | 返回 `passphrase_database_mismatch`,不写缓存,恢复官方微信 | +| 本机密钥展示 | 捕获成功并进入数据库解密 | 仅回环接口在完整数据库校验和安全保存后返回 `db_key`,前端验证 64 位十六进制格式后显示;远端请求被拒绝 | +| 监测启动时序 | 管理员授权期间轮询捕获状态 | 仅在 `monitor_ready=true` 后提示用户登录,避免授权前登录导致漏抓 | | 进程/应用异常退出 | 保留已 fsync 的事务状态后重新进入 | 下次启动优先调用 stale recovery,状态目标和备份路径校验后恢复 | | 备份路径篡改 | 将状态中的备份改到所选目录之外 | 拒绝覆盖微信并保留可诊断状态 | diff --git a/docs/macos-wcdb-key-capture.md b/docs/macos-wcdb-key-capture.md index cc70e0ff..7a05e968 100644 --- a/docs/macos-wcdb-key-capture.md +++ b/docs/macos-wcdb-key-capture.md @@ -4,7 +4,7 @@ ## 本 Draft 的边界 -现有 macOS 受限原生 helper 仍是默认且优先的获取方式。只有 helper 返回经过允许的运行时、版本或进程附加失败,并且用户在前端再次阅读风险说明后,才会展示实验性 LLDB 兜底;组件完整性、签名或信任校验失败不会触发兜底。LLDB 不会静默运行,也不会替换默认路径。 +现有 macOS 受限原生 helper 仍是默认且优先的获取方式。只有 helper 返回经过允许的运行时、版本或进程附加失败,并且用户在前端再次阅读风险说明后,才会展示实验性本机调试兜底;组件完整性、签名或信任校验失败不会触发兜底。该流程不会静默运行,也不会在结束后保留调试签名。 核心文件: @@ -15,7 +15,7 @@ - `routers/keys.py`:提供 status、prepare、preflight、capture、cancel 五个分阶段 API,恢复目录由后端固定,前端不能传入任意恢复路径; - `frontend/pages/decrypt.vue`:helper 失败后的显式风险确认、分阶段操作提示、停止/切页恢复和遗留状态恢复入口。 -上述 LLDB API 强制只接受运行 WCDA 的 Mac 本机回环连接;即使聊天查看或总结开启了局域网/Tailscale 访问,远端设备也不能触发临时重签、调试或恢复操作。 +上述捕获 API 强制只接受运行 WCDA 的 Mac 本机回环连接;即使聊天查看或总结开启了局域网/Tailscale 访问,远端设备也不能触发临时重签、调试或恢复操作。捕获成功后,数据库密钥会写入权限受限的本机缓存,并返回给本机前端显示,方便用户复制和核对;密钥不会写入日志、诊断状态或版本库。 ## 安全状态机 @@ -24,9 +24,9 @@ 3. 在任何改动前创建并验证官方微信恢复归档,再将本机恢复状态以 `0600` 原子写入磁盘。 4. 只对一次捕获所需的临时实例进行调试签名。先登录进入聊天页,再短暂附加做断点预检并立即分离。 5. 用户在未监测状态退出账号;正式监测开始后重新登录同一账号。 -6. 通用路径只接受两种经过验证的 WCDB 参数形状:`rounds=256000` 时 salt 必须等于目标数据库 salt;`rounds=2` 时 salt 必须等于目标数据库 salt 逐字节异或 `0x3A` 后的 HMAC salt。其他轮数一律拒绝。已知版本还可使用按模块 UUID 明确登记的内部返回点,未知 UUID 不猜偏移。 +6. 通用路径只接受两种经过验证的 WCDB 参数形状:`rounds=256000` 时 salt 必须等于目标数据库 salt;`rounds=2` 时 salt 必须等于目标数据库 salt 逐字节异或 `0x3A` 后的 HMAC salt。其他轮数一律拒绝。两类候选都先通过目标页 HMAC;前端 API 还要求同一候选通过 message + session 双角色校验后才缓存。已知版本还可使用按模块 UUID 明确登记的内部返回点,未知 UUID 不猜偏移。 7. 32 字节候选先通过目标数据库首页 HMAC 校验;API 还会校验同一账号的消息库与会话库,全部通过后才写入缓存。缓存目录和文件分别使用 `0700`、`0600`。 -8. LLDB 在 `process continue` 返回后记录隔离微信的 PID、进程状态、退出码和不超过 240 字符的退出原因;副本提前退出时立即返回专用诊断,不继续等待为普通超时。 +8. 原生监测器设置捕获点后先写入只含状态和 PID 的就绪文件;前端确认该状态后才提示用户重新登录。成功候选通过页面 HMAC 后,临时调试进程在受控异常状态中终止,避免残留 SIGTRAP 被重新投递成微信崩溃。 9. 成功、取消、超时或异常都进入同一恢复路径;恢复后再次执行腾讯签名和版本校验,最后才删除恢复状态。若桌面应用被强制结束,重新进入解密页时会先检测遗留状态,并由用户明确确认恢复。 ## 源码复现 @@ -103,8 +103,9 @@ python tools/audit_macos_key_capture_release.py \ ## 当前兼容性 - 已验证:Apple Silicon、微信 4.1.12(build 269341)、活动 `Documents/app_data/xwechat_files` 数据库; -- 社区实测反馈:微信 4.1.13(build 269578)出现 `rounds=2` 的页 HMAC 子密钥派生调用,其 32 字节 password 可作为数据库主密钥候选;本实现仅在 HMAC salt 精确对应目标库且候选通过该库首页 HMAC 时接受; +- 社区实测反馈:微信 4.1.11(build 269136,MAS)使用无受限 entitlement 的 APFS 调试副本、`CCKeyDerivationPBKDF` 和 `rounds=256000` 候选可完成 25/25 数据库解密;该记录为社区手工复现,不等同于本 PR 自动流程已完成同环境验收; +- 4.1.13 build 269578 的社区实测显示 `rounds=2` password 可作为 raw encryption key;本实现只在 HMAC salt、目标页 HMAC 和完整账号双角色校验全部通过时接受;Apple Silicon、macOS 27.0 的本机调试路径已完成一次真实登录捕获、数据库校验和官方签名恢复验证; - 同一社区环境报告隔离副本在手机确认登录后静默退出。当前修复增加退出状态诊断,但尚未在该机器上证明副本登录问题已经消失; -- 未验证:Intel Mac、微信 4.1.13 build 269578 的完整 clone 成功路径、未来微信版本、多套并行安装; +- 未验证:Intel Mac、未来微信版本、多套并行安装;真实通过记录不能推导为百分之百成功保证; - 已知内部返回点仅按 Mach-O UUID 精确启用;版本未知时只保留系统 PBKDF2 路径并返回明确诊断; - 断点预检不通过时流程必须停止,不能要求用户继续退出账号。 diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index cfd51a1a..ce0c876c 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -656,8 +656,8 @@ export const useApi = () => { return await request(url, params?.signal ? { signal: params.signal } : {}) } - const getMacosKeyCaptureStatus = async () => { - return await request('/macos-key-capture/status') + const getMacosKeyCaptureStatus = async (params = {}) => { + return await request('/macos-key-capture/status', params?.signal ? { signal: params.signal } : {}) } const macosKeyCaptureRequest = async (action, params = {}) => { diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 8ed8bff2..028c99eb 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -81,7 +81,7 @@ {{ isMacos - ? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性 LLDB 兜底。密钥只在本机校验,不会上传。' + ? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性本机调试兜底。获取接口仅允许本机访问。' : '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}

@@ -119,7 +119,7 @@ id="dbPath" v-model="formData.db_storage_path" type="text" - :placeholder="isMacos ? '例如: /Users/你的用户名/.../wxid_xxx/db_storage' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'" + :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'" class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200" :class="{ 'border-red-500': formErrors.db_storage_path }" required @@ -1446,7 +1446,7 @@ const showDbKeyPersistenceWarning = (result) => { const runMacosLldbFallback = async ({ requestRevision, requestController, helperError }) => { const riskAccepted = await requestGuideDialog({ eyebrow: '实验性兜底方式', - title: '受控组件失败,是否改用 LLDB 兜底?', + title: '受控组件失败,是否改用本机调试兜底?', description: '该方式会在管理员授权后临时重签默认路径中的微信,并在成功、失败或停止时恢复已校验的腾讯官方版本。', errorMessage: helperError ? `受控组件未能获取密钥:${helperError}` : '', details: [ @@ -1502,7 +1502,7 @@ const runMacosLldbFallback = async ({ requestRevision, requestController, helper return false } - warning.value = '正在短暂附加 LLDB 检查断点,完成后会立即脱离。' + warning.value = '正在短暂检查本机捕获点,完成后会立即脱离。' response = await preflightMacosKeyCapture({ ...macosKeyCapturePayload(), signal: requestController.signal @@ -1514,7 +1514,7 @@ const runMacosLldbFallback = async ({ requestRevision, requestController, helper if (response?.status !== 0) { macosKeyCapturePrepared.value = response?.data?.needs_cleanup === true macosKeyCaptureOwnedByPage.value = macosKeyCapturePrepared.value - error.value = response?.errmsg || '当前微信版本未通过 LLDB 断点预检,已停止并恢复。' + error.value = response?.errmsg || '当前微信版本未通过本机捕获点预检,已停止并恢复。' warning.value = '' if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) return false @@ -1539,28 +1539,57 @@ const runMacosLldbFallback = async ({ requestRevision, requestController, helper return false } - warning.value = '监测已启动:现在请扫码或在手机上确认登录,完成前不要关闭微信或 WCDA。' - response = await captureMacosKey({ + warning.value = '正在等待管理员授权并启动本机监测;显示“监测已就绪”前请不要登录微信。' + const captureOutcomePromise = captureMacosKey({ ...macosKeyCapturePayload(), signal: requestController.signal - }) + }).then( + captureResponse => ({ response: captureResponse, captureError: null }), + captureError => ({ response: null, captureError }) + ) + let captureOutcome = null + let monitorReady = false + while (isDbKeyRequestActive(requestRevision, requestController) && !captureOutcome && !monitorReady) { + captureOutcome = await Promise.race([ + captureOutcomePromise, + waitForDbKeyDelay(350, requestController.signal).then(() => null) + ]) + if (captureOutcome) break + try { + const statusResponse = await getMacosKeyCaptureStatus({ signal: requestController.signal }) + monitorReady = statusResponse?.status === 0 && statusResponse?.data?.monitor_ready === true + } catch (statusError) { + if (statusError?.name === 'AbortError') throw statusError + } + } if (!isDbKeyRequestActive(requestRevision, requestController)) { await cleanupMacosKeyCapture({ silent: true }) return false } + if (monitorReady) { + warning.value = '监测已就绪:现在请扫码或在手机上确认登录,完成前不要关闭微信或 WCDA。' + } + if (!captureOutcome) captureOutcome = await captureOutcomePromise + if (captureOutcome.captureError) throw captureOutcome.captureError + response = captureOutcome.response macosKeyCapturePrepared.value = response?.data?.needs_cleanup === true macosKeyCaptureOwnedByPage.value = macosKeyCapturePrepared.value const key = String(response?.data?.db_key || '').trim().toLowerCase() - if (response?.status === 0 && /^[0-9a-f]{64}$/.test(key)) { + if ( + response?.status === 0 + && response?.data?.validated === true + && response?.data?.key_saved === true + && /^[0-9a-f]{64}$/.test(key) + ) { macosKeyCapturePrepared.value = false macosKeyCaptureOwnedByPage.value = false formData.key = key warning.value = response?.data?.official_wechat_verified - ? '数据库密钥已通过完整数据库校验,腾讯官方签名微信已恢复。' - : '数据库密钥已获取并通过完整数据库校验。' + ? '数据库密钥已通过完整校验并显示在输入框中,腾讯官方签名微信已恢复。' + : '数据库密钥已获取、通过完整校验并显示在输入框中。' return true } - error.value = response?.errmsg || '实验性 LLDB 未能获取可验证的数据库密钥。' + error.value = response?.errmsg || '实验性本机调试未能获取可验证的数据库密钥。' warning.value = '' if (macosKeyCapturePrepared.value) await cleanupMacosKeyCapture({ silent: true }) return false @@ -2479,7 +2508,7 @@ const confirmBackFromRunningStep = () => { ? { title: '数据库密钥仍在获取', description: isMacos.value - ? '返回账号选择会停止当前获取;如果已进入 LLDB 兜底,系统会同时尝试恢复腾讯官方签名微信。' + ? '返回账号选择会停止当前获取;如果已进入本机调试兜底,系统会同时尝试恢复腾讯官方签名微信。' : '返回账号选择会停止当前页面等待结果;如果 Hook 已经开始,微信重启或登录流程仍可能继续完成。', details: isMacos.value ? ['页面将不再接收本次密钥结果', '恢复完成前请不要手动启动或更新微信'] @@ -2659,7 +2688,7 @@ const recoverPendingMacosKeyCapture = async () => { if (shouldRestore) { await cleanupMacosKeyCapture() } else { - warning.value = '仍有未完成的临时调试微信恢复状态;恢复前不会开始新的 LLDB 获取。' + warning.value = '仍有未完成的临时调试微信恢复状态;恢复前不会开始新的本机调试获取。' } } catch (statusError) { logDecryptDebug('macos-key-capture:status-error', { error: formatLogError(statusError) }) diff --git a/pyproject.toml b/pyproject.toml index 5f40beb4..b4336e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ include = [ "src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js", "src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js", "src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm", + "src/wechat_decrypt_tool/native/macos/source/*.c", "src/wechat_decrypt_tool/resources/*.json", ] diff --git a/src/wechat_decrypt_tool/macos_clone_capture.py b/src/wechat_decrypt_tool/macos_clone_capture.py index 1301ed92..8a7bffd0 100644 --- a/src/wechat_decrypt_tool/macos_clone_capture.py +++ b/src/wechat_decrypt_tool/macos_clone_capture.py @@ -716,6 +716,7 @@ def preflight_capture_breakpoints(*, pid: int, debug_root: Path = DEFAULT_DEBUG_ command_path.write_text( "settings set target.preload-symbols false\n" f"process attach -p {int(pid)}\n" + "process handle SIGTRAP -n false -p false -s false\n" f"command script import {callback_path}\n" "wedata_preflight\n" "quit\n", @@ -799,6 +800,7 @@ def capture_salt_matched_passphrase( command_path.write_text( "settings set target.preload-symbols false\n" f"process attach -p {int(pid)}\n" + "process handle SIGTRAP -n false -p false -s false\n" f"command script import {callback_path}\n" "wedata_capture\n" "process continue\n" diff --git a/src/wechat_decrypt_tool/macos_inplace_capture.py b/src/wechat_decrypt_tool/macos_inplace_capture.py index ae0087f3..9abe8276 100644 --- a/src/wechat_decrypt_tool/macos_inplace_capture.py +++ b/src/wechat_decrypt_tool/macos_inplace_capture.py @@ -12,6 +12,7 @@ import json import os import platform +import subprocess import time from pathlib import Path from typing import Any @@ -19,17 +20,17 @@ from .macos_clone_capture import ( _breakpoint_preflight_path, _remove_breakpoint_preflight, - capture_salt_matched_passphrase, - preflight_capture_breakpoints, ) from .macos_db_key_capture import ( DEFAULT_DEBUG_ROOT, DEFAULT_WECHAT_APP, MacOSDBKeyCaptureFailure, + _find_wechat_bundle_pids, _find_wechat_main_pid, _has_compatible_in_place_signature, _is_tencent_official_signature, _launch_wechat, + _run_as_administrator, _validate_captured_passphrase, ensure_wechat_in_place_debuggable, inspect_wechat_signature, @@ -37,8 +38,13 @@ restore_official_wechat_if_needed, save_passphrase, ) +from .macos_native_capture import ( + capture_native_wcdb_key, + preflight_native_wcdb_capture, +) IN_PLACE_STATE_NAME = "prepared-in-place-capture.json" +NATIVE_CAPTURE_READY_NAME = "native-capture-ready.json" STATE_SCHEMA_VERSION = 1 @@ -46,6 +52,46 @@ def _state_path(debug_root: Path) -> Path: return debug_root.expanduser() / IN_PLACE_STATE_NAME +def _native_capture_ready_path(debug_root: Path) -> Path: + return debug_root.expanduser() / NATIVE_CAPTURE_READY_NAME + + +def _prepare_native_capture_ready(debug_root: Path) -> Path: + target = _native_capture_ready_path(debug_root) + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + try: + target.unlink() + except FileNotFoundError: + pass + target.touch(mode=0o600, exist_ok=False) + os.chmod(target, 0o600) + return target + + +def native_capture_monitor_ready(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> bool: + """Return true only after the native monitor has armed its breakpoint.""" + + target = _native_capture_ready_path(debug_root) + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + return False + return bool( + isinstance(payload, dict) + and payload.get("status") == "ready" + and payload.get("method") == "macos_native_mach" + and int(payload.get("pid") or 0) > 0 + ) + + +def _remove_native_capture_ready(debug_root: Path) -> None: + try: + _native_capture_ready_path(debug_root).unlink() + except FileNotFoundError: + pass + + def has_pending_in_place_capture(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> bool: return _state_path(debug_root).is_file() @@ -58,6 +104,7 @@ def get_in_place_capture_status(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> dic "pending": False, "stage": "idle", "needs_cleanup": False, + "monitor_ready": False, } try: state = _read_state(debug_root) @@ -70,9 +117,70 @@ def get_in_place_capture_status(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> dic "pending": True, "stage": stage, "needs_cleanup": True, + "monitor_ready": native_capture_monitor_ready(debug_root=debug_root), } +def _native_capture_process_targets(debug_root: Path) -> tuple[list[int], list[int]]: + helper_path = str(debug_root.expanduser() / "native" / "wcdb-native-capture") + result = subprocess.run( + ["/bin/ps", "-axo", "pid=,user=,command="], + capture_output=True, + text=True, + check=False, + ) + osascript_pids: list[int] = [] + helper_pids: list[int] = [] + for raw_line in str(result.stdout or "").splitlines(): + line = raw_line.strip() + if not line or helper_path not in line: + continue + parts = line.split(None, 2) + if len(parts) < 3: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + command = parts[2] + if command.startswith("/usr/bin/osascript ") and helper_path in command: + osascript_pids.append(pid) + elif command.startswith(helper_path): + helper_pids.append(pid) + return osascript_pids, helper_pids + + +def _terminate_native_capture_processes(debug_root: Path) -> None: + osascript_pids, helper_pids = _native_capture_process_targets(debug_root) + if osascript_pids: + subprocess.run( + ["/bin/kill", "-TERM", *(str(pid) for pid in osascript_pids)], + capture_output=True, + text=True, + check=False, + ) + if helper_pids: + try: + _run_as_administrator( + "/bin/kill -TERM " + + " ".join(str(pid) for pid in helper_pids) + + " 2>/dev/null || true; /bin/sleep 1; /bin/kill -KILL " + + " ".join(str(pid) for pid in helper_pids) + + " 2>/dev/null || true", + timeout=20, + ) + except MacOSDBKeyCaptureFailure: + pass + lingering_osascript, _ = _native_capture_process_targets(debug_root) + if lingering_osascript: + subprocess.run( + ["/bin/kill", "-KILL", *(str(pid) for pid in lingering_osascript)], + capture_output=True, + text=True, + check=False, + ) + + def _write_state(debug_root: Path, payload: dict[str, Any]) -> Path: target = _state_path(debug_root) target.parent.mkdir(parents=True, exist_ok=True) @@ -107,6 +215,51 @@ def _write_state(debug_root: Path, payload: dict[str, Any]) -> Path: return target +def _write_preflight_result(debug_root: Path, payload: dict[str, Any]) -> Path: + target = _breakpoint_preflight_path(debug_root) + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp") + encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(temporary, flags, 0o600) + try: + view = memoryview(encoded) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + temporary.replace(target) + os.chmod(target, 0o600) + return target + + +def _write_probe_page1(debug_root: Path, page1: bytes) -> Path: + target = debug_root.expanduser() / f"probe-page1-{os.getpid()}.bin" + target.parent.mkdir(parents=True, exist_ok=True) + os.chmod(target.parent, 0o700) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(target, flags, 0o600) + try: + view = memoryview(page1) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + os.chmod(target, 0o600) + return target + + def _read_state(debug_root: Path) -> dict[str, Any]: target = _state_path(debug_root) try: @@ -172,6 +325,7 @@ def recover_stale_in_place_capture( """Restore a Tencent-signed bundle from a previously recorded transaction.""" wechat_app = normalize_wechat_app_path(wechat_install_path) + _terminate_native_capture_processes(debug_root) if not has_pending_in_place_capture(debug_root=debug_root): signature = inspect_wechat_signature(wechat_app) if not _is_tencent_official_signature(signature): @@ -205,6 +359,7 @@ def recover_stale_in_place_capture( wechat_modified=True, ) _remove_breakpoint_preflight(debug_root) + _remove_native_capture_ready(debug_root) # Remove durable state last. If the process dies after the atomic app # exchange but before this unlink, the next startup observes an official # installation, removes any displaced staging bundle, and finishes safely. @@ -324,6 +479,7 @@ def cleanup_in_place_capture( debug_root: Path = DEFAULT_DEBUG_ROOT, ) -> dict[str, Any]: wechat_app = normalize_wechat_app_path(wechat_install_path) + _terminate_native_capture_processes(debug_root) if has_pending_in_place_capture(debug_root=debug_root): recovery = recover_stale_in_place_capture( wechat_app, @@ -378,6 +534,44 @@ def _require_prepared_process( return state, current_pid +def _candidate_bundle_pids(wechat_app: Path, main_pid: int) -> list[int]: + pids = [main_pid] + for value in _find_wechat_bundle_pids(wechat_app): + if value > 0 and value not in pids: + pids.append(value) + + commands: dict[int, str] = {} + loaded_crypto_image: dict[int, bool] = {} + crypto_image = str(wechat_app / "Contents" / "Resources" / "wechat.dylib") + for value in pids: + result = subprocess.run( + ["/bin/ps", "-p", str(value), "-o", "command="], + capture_output=True, + text=True, + check=False, + ) + commands[value] = str(result.stdout or "").strip() + try: + vmmap = subprocess.run( + ["/usr/bin/vmmap", str(value)], + capture_output=True, + text=True, + check=False, + timeout=3, + ) + loaded_crypto_image[value] = crypto_image in f"{vmmap.stdout}\n{vmmap.stderr}" + except (OSError, subprocess.TimeoutExpired): + loaded_crypto_image[value] = False + return sorted( + pids, + key=lambda value: ( + 0 if loaded_crypto_image.get(value, False) else 1, + 0 if value == main_pid else 1, + 0 if "/WeChatAppEx.app/Contents/MacOS/WeChatAppEx" in commands.get(value, "") else 1, + ), + ) + + def preflight_prepared_in_place_capture( wechat_install_path: str | Path | None, *, @@ -391,7 +585,29 @@ def preflight_prepared_in_place_capture( backup_root=backup_root, debug_root=debug_root, ) - result = preflight_capture_breakpoints(pid=debug_pid, debug_root=debug_root) + result: dict[str, Any] | None = None + last_error: MacOSDBKeyCaptureFailure | None = None + for candidate_pid in _candidate_bundle_pids(wechat_app, debug_pid): + try: + result = preflight_native_wcdb_capture( + pid=candidate_pid, + wechat_app=wechat_app, + debug_root=debug_root, + ) + break + except MacOSDBKeyCaptureFailure as exc: + last_error = exc + if exc.code != "native_image_not_found": + raise + if result is None: + if last_error is not None: + raise last_error + raise MacOSDBKeyCaptureFailure( + "native_image_not_found", + "临时微信进程中没有找到可用于原生预检的 wechat.dylib。", + process_attached=True, + ) + _write_preflight_result(debug_root, result) state["stage"] = "preflight_passed" _write_state(debug_root, state) except Exception as exc: @@ -405,7 +621,7 @@ def preflight_prepared_in_place_capture( raise result.update( { - "method": "macos_inplace_lldb_preflight", + "method": "macos_inplace_native_preflight", "debug_app_path": str(wechat_app), "official_wechat_preserved": False, "wechat_modified": True, @@ -426,6 +642,8 @@ def capture_prepared_in_place( ) -> dict[str, Any]: wechat_app = normalize_wechat_app_path(wechat_install_path) cache_path: Path | None = None + probe_page1_path: Path | None = None + ready_path: Path | None = None state: dict[str, Any] = {} recovery: dict[str, Any] = {} try: @@ -447,31 +665,29 @@ def capture_prepared_in_place( ) from exc if len(probe_page1) < 4096 or probe_page1.startswith(b"SQLite format 3"): raise MacOSDBKeyCaptureFailure("probe_database_invalid", f"目标数据库不是有效的加密 WCDB: {probe_database}") + probe_page1_path = _write_probe_page1(debug_root, probe_page1) preflight_path = _breakpoint_preflight_path(debug_root) try: preflight = json.loads(preflight_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, ValueError) as exc: - raise MacOSDBKeyCaptureFailure( - "capture_preflight_required", - "尚未完成断点预检;监测未启动,将恢复腾讯原版微信。", - ) from exc - if int(preflight.get("pid") or 0) != debug_pid or ( - int(preflight.get("pbkdf_locations") or 0) <= 0 - and int(preflight.get("key_return_locations") or 0) <= 0 - ): - raise MacOSDBKeyCaptureFailure( - "capture_preflight_stale", - "断点预检结果与当前微信进程不匹配,将恢复腾讯原版微信。", - ) - - passphrase = capture_salt_matched_passphrase( - pid=debug_pid, - expected_salts=[probe_page1[:16].hex()], + except (OSError, UnicodeError, ValueError): + preflight = {} + preflight_pid = int(preflight.get("pid") or debug_pid) + candidate_pids = _candidate_bundle_pids(wechat_app, debug_pid) + if preflight_pid not in candidate_pids: + preflight_pid = candidate_pids[0] if candidate_pids else debug_pid + + ready_path = _prepare_native_capture_ready(debug_root) + capture = capture_native_wcdb_key( + pid=preflight_pid, + wechat_app=wechat_app, probe_db_path=probe_database, + probe_page1_path=probe_page1_path, + ready_file=ready_path, timeout=timeout, - enable_key_return_fallback=int(preflight.get("key_return_locations") or 0) > 0, + debug_root=debug_root, ) + passphrase = str(capture.get("db_key") or "") _validate_captured_passphrase(passphrase, probe_database) if save_result: cache_path = save_passphrase(passphrase) @@ -490,11 +706,18 @@ def capture_prepared_in_place( backup_root=backup_root, debug_root=debug_root, ) + finally: + _remove_native_capture_ready(debug_root) + if probe_page1_path is not None: + try: + probe_page1_path.unlink() + except FileNotFoundError: + pass if save_result and cache_path is None: raise MacOSDBKeyCaptureFailure("passphrase_not_saved", "passphrase 未能安全保存") return { - "method": "macos_inplace_lldb_passphrase", + "method": str(capture.get("method") or "macos_native_mach"), "db_key": passphrase, "cache_path": str(cache_path) if cache_path is not None else "", "wechat_modified": False, @@ -517,6 +740,7 @@ def capture_prepared_in_place( "cleanup_in_place_capture", "get_in_place_capture_status", "has_pending_in_place_capture", + "native_capture_monitor_ready", "preflight_prepared_in_place_capture", "prepare_in_place_capture", "recover_stale_in_place_capture", diff --git a/src/wechat_decrypt_tool/macos_native_capture.py b/src/wechat_decrypt_tool/macos_native_capture.py new file mode 100644 index 00000000..0c63898a --- /dev/null +++ b/src/wechat_decrypt_tool/macos_native_capture.py @@ -0,0 +1,344 @@ +"""Lightweight macOS WCDB key capture helper orchestration. + +This module replaces the LLDB Python frontend for the in-place capture path. +The privileged operation is reduced to a small native helper that can preflight +the PBKDF import stub and capture the 32-byte key with lower memory overhead. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shlex +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any + +from .macos_db_key_capture import ( + DEFAULT_DEBUG_ROOT, + DEFAULT_WECHAT_APP, + MacOSDBKeyCaptureFailure, + _PASSPHRASE_RE, + _run, + _run_as_administrator, +) + +_STUB_HEADER = "Indirect symbols for (__TEXT,__stubs)" +_SECTION_HEADER_RE = re.compile(r"^Indirect symbols for \(([^)]+)\)") +_STUB_LINE_RE = re.compile(r"^\s*(0x[0-9a-fA-F]+)\s+\d+\s+_CCKeyDerivationPBKDF\s*$") +_SEGNAME_TEXT_RE = re.compile(r"^\s*segname\s+__TEXT\s*$") +_VMADDR_RE = re.compile(r"^\s*vmaddr\s+(0x[0-9a-fA-F]+)\s*$") +_VMMAP_TEXT_LINE_RE = re.compile(r"^__TEXT\s+([0-9a-fA-F]+)-[0-9a-fA-F]+\s+.*?\s+(\/.*)$") +_JSON_LIMIT = 128 * 1024 +_HELPER_TIMEOUT_PAD = 45 +_HELPER_NAME = "wcdb-native-capture" +_HELPER_SOURCE = Path(__file__).resolve().parent / "native" / "macos" / "source" / "wcdb_native_capture.c" + + +def _resolve_wechat_dylib(wechat_app: Path) -> Path: + candidate = wechat_app / "Contents" / "Resources" / "wechat.dylib" + if not candidate.is_file(): + raise MacOSDBKeyCaptureFailure( + "native_wechat_dylib_missing", + f"找不到微信原生模块: {candidate}", + ) + return candidate + + +def _parse_pbkdf_stub_address(output: str) -> int: + in_stub_section = False + for raw_line in str(output or "").splitlines(): + line = raw_line.rstrip() + header = _SECTION_HEADER_RE.match(line) + if header: + in_stub_section = line.startswith(_STUB_HEADER) + continue + if not in_stub_section: + continue + matched = _STUB_LINE_RE.match(line) + if matched: + return int(matched.group(1), 16) + raise MacOSDBKeyCaptureFailure( + "native_pbkdf_stub_missing", + "未能在微信 wechat.dylib 的 __TEXT,__stubs 中找到 PBKDF 导入桩。", + ) + + +def _resolve_pbkdf_stub_address(dylib_path: Path) -> int: + result = _run( + ["/usr/bin/otool", "-arch", "arm64", "-Iv", str(dylib_path)], + timeout=60, + ) + return _parse_pbkdf_stub_address(result.stdout) + + +def _resolve_text_vmaddr(dylib_path: Path) -> int: + result = _run( + ["/usr/bin/otool", "-arch", "arm64", "-l", str(dylib_path)], + timeout=60, + ) + inside_text = False + for raw_line in str(result.stdout or "").splitlines(): + line = raw_line.rstrip() + if _SEGNAME_TEXT_RE.match(line): + inside_text = True + continue + if inside_text: + matched = _VMADDR_RE.match(line) + if matched: + return int(matched.group(1), 16) + if line.strip().startswith("segname "): + inside_text = False + raise MacOSDBKeyCaptureFailure( + "native_text_vmaddr_missing", + "未能解析 wechat.dylib 的 __TEXT 虚拟地址。", + ) + + +def _resolve_loaded_text_base(pid: int, dylib_path: Path) -> int: + expected = str(dylib_path) + deadline = time.monotonic() + 20.0 + while True: + result = _run( + ["/usr/bin/vmmap", str(int(pid))], + timeout=60, + check=False, + ) + output = f"{result.stdout}\n{result.stderr}" + for raw_line in output.splitlines(): + matched = _VMMAP_TEXT_LINE_RE.match(raw_line) + if not matched: + continue + if matched.group(2).strip() == expected: + return int(matched.group(1), 16) + if time.monotonic() >= deadline: + break + time.sleep(0.5) + raise MacOSDBKeyCaptureFailure( + "native_vmmap_image_missing", + "vmmap 中没有找到已加载的 wechat.dylib __TEXT 映像。", + ) + + +def _resolve_runtime_breakpoint_address(pid: int, dylib_path: Path, stub_file_address: int) -> int: + text_base = _resolve_loaded_text_base(pid, dylib_path) + text_vmaddr = _resolve_text_vmaddr(dylib_path) + return text_base + (int(stub_file_address) - text_vmaddr) + 8 + + +def _native_helper_path(debug_root: Path) -> Path: + return debug_root.expanduser() / "native" / _HELPER_NAME + + +def _source_digest() -> str: + try: + return hashlib.sha256(_HELPER_SOURCE.read_bytes()).hexdigest() + except OSError as exc: + raise MacOSDBKeyCaptureFailure( + "native_helper_source_missing", + f"找不到原生捕获器源码: {_HELPER_SOURCE}", + ) from exc + + +def ensure_native_capture_helper(*, debug_root: Path = DEFAULT_DEBUG_ROOT) -> Path: + helper_path = _native_helper_path(debug_root) + digest_path = helper_path.with_suffix(".sha256") + expected_digest = _source_digest() + try: + if ( + helper_path.is_file() + and os.access(helper_path, os.X_OK) + and digest_path.read_text(encoding="utf-8").strip() == expected_digest + ): + return helper_path + except OSError: + pass + + helper_path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(helper_path.parent, 0o700) + with tempfile.TemporaryDirectory(prefix="wcdb-native-build-", dir=str(helper_path.parent)) as temp_dir: + staged = Path(temp_dir) / _HELPER_NAME + _run( + [ + "/usr/bin/xcrun", + "clang", + "-arch", + "arm64", + "-O2", + "-Wall", + "-Wextra", + "-std=c11", + "-o", + str(staged), + str(_HELPER_SOURCE), + ], + timeout=300, + ) + os.chmod(staged, 0o700) + staged.replace(helper_path) + helper_path.chmod(0o700) + digest_path.write_text(expected_digest, encoding="utf-8") + os.chmod(digest_path, 0o600) + return helper_path + + +def _run_helper( + *, + mode: str, + pid: int, + wechat_app: Path, + debug_root: Path, + stub_file_address: int | None = None, + probe_db_path: Path | None = None, + probe_page1_path: Path | None = None, + ready_file: Path | None = None, + timeout: int = 240, +) -> dict[str, Any]: + if pid <= 0: + raise MacOSDBKeyCaptureFailure("native_capture_invalid_pid", "微信调试进程 PID 无效") + + helper_path = ensure_native_capture_helper(debug_root=debug_root) + dylib = _resolve_wechat_dylib(wechat_app) + stub_address = int(stub_file_address or _resolve_pbkdf_stub_address(dylib)) + breakpoint_address = _resolve_runtime_breakpoint_address(pid, dylib, stub_address) + if stub_address <= 0: + raise MacOSDBKeyCaptureFailure("native_capture_invalid_stub", "PBKDF 导入桩地址无效") + if breakpoint_address <= 0: + raise MacOSDBKeyCaptureFailure("native_capture_invalid_breakpoint", "PBKDF 运行时断点地址无效") + + command = [ + str(helper_path), + "--mode", + mode, + "--pid", + str(pid), + "--stub-file-address", + hex(stub_address), + "--breakpoint-address", + hex(breakpoint_address), + ] + if probe_page1_path is not None: + command.extend(["--page1-file", str(probe_page1_path.expanduser())]) + elif probe_db_path is not None: + command.extend(["--database", str(probe_db_path.expanduser())]) + if mode == "capture": + command.extend(["--timeout", str(max(int(timeout), 30))]) + if ready_file is not None: + command.extend(["--ready-file", str(ready_file.expanduser())]) + + try: + raw_output = _run_as_administrator( + shlex.join(command), + timeout=max(int(timeout), 30) + _HELPER_TIMEOUT_PAD, + ).strip() + except MacOSDBKeyCaptureFailure as exc: + embedded = _extract_embedded_json(str(exc)) + if embedded is None: + raise + raw_output = embedded + if not raw_output: + raise MacOSDBKeyCaptureFailure("native_capture_empty", "原生捕获器没有返回结果") + if len(raw_output) > _JSON_LIMIT: + raise MacOSDBKeyCaptureFailure("native_capture_oversized", "原生捕获器返回异常长输出,已拒绝解析") + try: + payload = json.loads(raw_output) + except ValueError as exc: + raise MacOSDBKeyCaptureFailure("native_capture_non_json", "原生捕获器返回了无效结果") from exc + if not isinstance(payload, dict): + raise MacOSDBKeyCaptureFailure("native_capture_invalid_payload", "原生捕获器返回结构无效") + + status = str(payload.get("status") or "").strip().lower() + if status != "ok": + code = str(payload.get("code") or "native_capture_failed").strip() or "native_capture_failed" + message = str(payload.get("message") or "原生捕获器执行失败").strip() or "原生捕获器执行失败" + raise MacOSDBKeyCaptureFailure(code, message, process_attached=True) + if str(payload.get("mode") or "").strip().lower() != mode: + raise MacOSDBKeyCaptureFailure("native_capture_mode_mismatch", "原生捕获器返回模式不匹配") + if int(payload.get("pid") or 0) != pid: + raise MacOSDBKeyCaptureFailure("native_capture_pid_mismatch", "原生捕获器返回的微信进程不匹配") + if int(payload.get("stub_file_address") or 0) != stub_address: + raise MacOSDBKeyCaptureFailure("native_capture_stub_mismatch", "原生捕获器返回的断点地址不匹配") + if str(payload.get("method") or "") != "macos_native_mach": + raise MacOSDBKeyCaptureFailure("native_capture_method_mismatch", "原生捕获器返回的方法标识无效") + return payload + + +def _extract_embedded_json(message: str) -> str | None: + raw = str(message or "").strip() + start = raw.find("{") + end = raw.rfind("}") + if start < 0 or end <= start: + return None + candidate = raw[start : end + 1].strip() + try: + payload = json.loads(candidate) + except ValueError: + return None + return candidate if isinstance(payload, dict) else None + + +def preflight_native_wcdb_capture( + *, + pid: int, + wechat_app: Path = DEFAULT_WECHAT_APP, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + payload = _run_helper( + mode="preflight", + pid=pid, + wechat_app=wechat_app, + debug_root=debug_root, + timeout=90, + ) + payload.pop("db_key", None) + payload.pop("validated", None) + return payload + + +def capture_native_wcdb_key( + *, + pid: int, + wechat_app: Path = DEFAULT_WECHAT_APP, + probe_db_path: Path, + probe_page1_path: Path | None = None, + ready_file: Path | None = None, + timeout: int = 240, + debug_root: Path = DEFAULT_DEBUG_ROOT, +) -> dict[str, Any]: + payload = _run_helper( + mode="capture", + pid=pid, + wechat_app=wechat_app, + debug_root=debug_root, + probe_db_path=probe_db_path, + probe_page1_path=probe_page1_path, + ready_file=ready_file, + timeout=timeout, + ) + if not bool(payload.get("validated")): + raise MacOSDBKeyCaptureFailure( + "native_capture_unvalidated", + "原生捕获器拿到了候选密钥,但没有通过数据库校验。", + process_attached=True, + ) + key = str(payload.get("db_key") or "").strip().lower() + if not _PASSPHRASE_RE.fullmatch(key): + raise MacOSDBKeyCaptureFailure( + "native_capture_invalid_key", + "原生捕获器返回的数据库密钥格式无效。", + process_attached=True, + ) + payload["db_key"] = key + return payload + + +__all__ = [ + "_parse_pbkdf_stub_address", + "capture_native_wcdb_key", + "ensure_native_capture_helper", + "preflight_native_wcdb_capture", +] diff --git a/src/wechat_decrypt_tool/native/macos/source/wcdb_native_capture.c b/src/wechat_decrypt_tool/native/macos/source/wcdb_native_capture.c new file mode 100644 index 00000000..8aa24ca2 --- /dev/null +++ b/src/wechat_decrypt_tool/native/macos/source/wcdb_native_capture.c @@ -0,0 +1,1048 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PAGE_SIZE_BYTES 4096 +#define SALT_SIZE 16 +#define KEY_SIZE 32 +#define IV_SIZE 16 +#define HMAC_SIZE 64 +#define RESERVE_SIZE (IV_SIZE + HMAC_SIZE) +#define BR_X16_INSN 0xD61F0200u +#define MAX_EXCEPTION_PORTS 16 +#define MAX_HW_BREAKPOINTS 16 +#define REMOTE_PATH_LIMIT 1024 +#define HW_BREAKPOINT_CONTROL_EXEC_4BYTES (((uint64_t)0xFull << 5) | 0x7ull) + +extern boolean_t exc_server(mach_msg_header_t *in, mach_msg_header_t *out); + +typedef struct { + char mode[16]; + pid_t pid; + uint64_t stub_file_address; + uint64_t breakpoint_address; + char image_name[256]; + char database_path[PATH_MAX]; + char page1_path[PATH_MAX]; + char ready_file[PATH_MAX]; + int timeout_seconds; +} options_t; + +typedef struct { + task_t task; + pid_t target_pid; + mach_port_t exception_port; + exception_mask_t masks[MAX_EXCEPTION_PORTS]; + mach_msg_type_number_t old_count; + mach_port_t old_ports[MAX_EXCEPTION_PORTS]; + exception_behavior_t old_behaviors[MAX_EXCEPTION_PORTS]; + thread_state_flavor_t old_flavors[MAX_EXCEPTION_PORTS]; + mach_vm_address_t breakpoint_address; + uint32_t original_instruction; + thread_act_array_t debug_threads; + mach_msg_type_number_t debug_thread_count; + arm_debug_state64_t *saved_debug_states; + bool hw_breakpoints_installed; + uint8_t page1[PAGE_SIZE_BYTES]; + bool page1_loaded; + bool captured; + bool validated; + char db_key_hex[65]; + uint64_t pbkdf_calls; + volatile sig_atomic_t stop_requested; +} capture_context_t; + +static capture_context_t g_ctx; + +static void json_success_preflight(const options_t *options) { + printf( + "{\"status\":\"ok\",\"mode\":\"preflight\",\"method\":\"macos_native_mach\"," + "\"pid\":%d,\"stub_file_address\":%" PRIu64 "}\n", + options->pid, + options->stub_file_address + ); +} + +static void json_success_capture(const options_t *options) { + (void)options; + printf( + "{\"status\":\"ok\",\"mode\":\"capture\",\"method\":\"macos_native_mach\"," + "\"pid\":%d,\"stub_file_address\":%" PRIu64 ",\"validated\":%s," + "\"db_key\":\"%s\",\"pbkdf_calls\":%" PRIu64 "}\n", + options->pid, + options->stub_file_address, + g_ctx.validated ? "true" : "false", + g_ctx.db_key_hex, + g_ctx.pbkdf_calls + ); +} + +static void json_error(const char *code, const char *message) { + printf( + "{\"status\":\"error\",\"code\":\"%s\",\"message\":\"%s\"}\n", + code ? code : "native_capture_failed", + message ? message : "native capture failed" + ); +} + +static void on_signal(int signum) { + (void)signum; + g_ctx.stop_requested = 1; +} + +static void install_signal_handlers(void) { + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = on_signal; + sigemptyset(&action.sa_mask); + sigaction(SIGINT, &action, NULL); + sigaction(SIGTERM, &action, NULL); +} + +static bool write_ready_file(const char *path, pid_t pid) { + if (!path || !*path) { + return true; + } + int flags = O_WRONLY | O_TRUNC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int descriptor = open(path, flags); + if (descriptor < 0) { + return false; + } + char payload[128]; + int length = snprintf( + payload, + sizeof(payload), + "{\"status\":\"ready\",\"method\":\"macos_native_mach\",\"pid\":%d}\n", + pid + ); + bool ok = length > 0 && (size_t)length < sizeof(payload) + && write(descriptor, payload, (size_t)length) == length + && fsync(descriptor) == 0; + close(descriptor); + return ok; +} + +static bool streq(const char *a, const char *b) { + return a && b && strcmp(a, b) == 0; +} + +static bool parse_uint64(const char *value, uint64_t *out) { + if (!value || !*value || !out) { + return false; + } + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 0); + if (errno != 0 || !end || *end != '\0') { + return false; + } + *out = (uint64_t)parsed; + return true; +} + +static bool parse_int(const char *value, int *out) { + if (!value || !*value || !out) { + return false; + } + errno = 0; + char *end = NULL; + long parsed = strtol(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed <= 0 || parsed > INT32_MAX) { + return false; + } + *out = (int)parsed; + return true; +} + +static bool parse_args(int argc, char **argv, options_t *options) { + if (!options) { + return false; + } + memset(options, 0, sizeof(*options)); + options->timeout_seconds = 240; + strlcpy(options->image_name, "wechat.dylib", sizeof(options->image_name)); + for (int index = 1; index < argc; index++) { + const char *arg = argv[index]; + if (streq(arg, "--mode") && index + 1 < argc) { + strlcpy(options->mode, argv[++index], sizeof(options->mode)); + } else if (streq(arg, "--pid") && index + 1 < argc) { + int pid_value = 0; + if (!parse_int(argv[++index], &pid_value)) { + return false; + } + options->pid = (pid_t)pid_value; + } else if (streq(arg, "--stub-file-address") && index + 1 < argc) { + if (!parse_uint64(argv[++index], &options->stub_file_address)) { + return false; + } + } else if (streq(arg, "--breakpoint-address") && index + 1 < argc) { + if (!parse_uint64(argv[++index], &options->breakpoint_address)) { + return false; + } + } else if (streq(arg, "--database") && index + 1 < argc) { + strlcpy(options->database_path, argv[++index], sizeof(options->database_path)); + } else if (streq(arg, "--page1-file") && index + 1 < argc) { + strlcpy(options->page1_path, argv[++index], sizeof(options->page1_path)); + } else if (streq(arg, "--ready-file") && index + 1 < argc) { + strlcpy(options->ready_file, argv[++index], sizeof(options->ready_file)); + } else if (streq(arg, "--timeout") && index + 1 < argc) { + if (!parse_int(argv[++index], &options->timeout_seconds)) { + return false; + } + } else if (streq(arg, "--image") && index + 1 < argc) { + strlcpy(options->image_name, argv[++index], sizeof(options->image_name)); + } else { + return false; + } + } + if (options->pid <= 0 || options->stub_file_address == 0 || options->mode[0] == '\0') { + return false; + } + if (!streq(options->mode, "preflight") && !streq(options->mode, "capture")) { + return false; + } + if (streq(options->mode, "capture") && options->database_path[0] == '\0' && options->page1_path[0] == '\0') { + return false; + } + return options->breakpoint_address > 0; +} + +static kern_return_t remote_read_exact(task_t task, mach_vm_address_t address, void *buffer, mach_vm_size_t size) { + mach_vm_size_t read_size = 0; + return mach_vm_read_overwrite(task, address, size, (mach_vm_address_t)buffer, &read_size) == KERN_SUCCESS && + read_size == size + ? KERN_SUCCESS + : KERN_FAILURE; +} + +static bool read_page1(const char *path, uint8_t *page1) { + int descriptor = open(path, O_RDONLY); + if (descriptor < 0) { + return false; + } + ssize_t total = 0; + while (total < PAGE_SIZE_BYTES) { + ssize_t read_now = read(descriptor, page1 + total, PAGE_SIZE_BYTES - total); + if (read_now <= 0) { + close(descriptor); + return false; + } + total += read_now; + } + close(descriptor); + return true; +} + +static void xor_mac_salt(const uint8_t *salt, uint8_t *mac_salt) { + for (size_t index = 0; index < SALT_SIZE; index++) { + mac_salt[index] = (uint8_t)(salt[index] ^ 0x3A); + } +} + +static bool derive_key(const uint8_t *password, size_t password_length, const uint8_t *salt, uint32_t rounds, uint8_t *out_key) { + return CCKeyDerivationPBKDF( + kCCPBKDF2, + (const char *)password, + password_length, + salt, + SALT_SIZE, + kCCPRFHmacAlgSHA512, + rounds, + out_key, + KEY_SIZE + ) == 0; +} + +static bool compute_page1_hmac(const uint8_t *mac_key, const uint8_t *page1, uint8_t *out_digest) { + CCHmacContext context; + CCHmacInit(&context, kCCHmacAlgSHA512, mac_key, KEY_SIZE); + CCHmacUpdate(&context, page1 + SALT_SIZE, PAGE_SIZE_BYTES - RESERVE_SIZE); + const uint32_t page_number = 1; + CCHmacUpdate(&context, &page_number, sizeof(page_number)); + CCHmacFinal(&context, out_digest); + return true; +} + +static bool candidate_matches_page1(const uint8_t *candidate) { + if (!g_ctx.page1_loaded) { + return false; + } + const uint8_t *salt = g_ctx.page1; + const uint8_t *stored_hmac = g_ctx.page1 + PAGE_SIZE_BYTES - HMAC_SIZE; + uint8_t enc_key[KEY_SIZE]; + uint8_t mac_salt[SALT_SIZE]; + uint8_t mac_key[KEY_SIZE]; + uint8_t digest[HMAC_SIZE]; + + /* + * Only accept the account passphrase seen at the 256000-round PBKDF call. + * The later 2-round call receives the already-derived per-database raw + * encryption key. Accepting that value validates one message database but + * cannot open the session database, so it must never be returned as the + * account passphrase. + */ + if (!derive_key(candidate, KEY_SIZE, salt, 256000, enc_key)) { + return false; + } + xor_mac_salt(salt, mac_salt); + if (!derive_key(enc_key, KEY_SIZE, mac_salt, 2, mac_key)) { + return false; + } + compute_page1_hmac(mac_key, g_ctx.page1, digest); + return memcmp(digest, stored_hmac, HMAC_SIZE) == 0; +} + +static void bytes_to_hex(const uint8_t *bytes, size_t length, char *out_hex) { + static const char table[] = "0123456789abcdef"; + for (size_t index = 0; index < length; index++) { + out_hex[index * 2] = table[(bytes[index] >> 4) & 0xF]; + out_hex[index * 2 + 1] = table[bytes[index] & 0xF]; + } + out_hex[length * 2] = '\0'; +} + +static bool breakpoint_operands_match(const arm_thread_state64_t *state) { + uint64_t algorithm = state->__x[0]; + uint64_t password_len = state->__x[2]; + uint64_t salt_len = state->__x[4]; + uint64_t prf = state->__x[5]; + uint64_t rounds = state->__x[6]; + return algorithm == 2 && password_len == KEY_SIZE && salt_len == SALT_SIZE && prf == 5 && + rounds == 256000; +} + +static bool salt_matches_expected(uint64_t rounds, const uint8_t *salt) { + uint8_t mac_salt[SALT_SIZE]; + xor_mac_salt(g_ctx.page1, mac_salt); + (void)mac_salt; + return rounds == 256000 && memcmp(salt, g_ctx.page1, SALT_SIZE) == 0; +} + +static kern_return_t attach_task(pid_t pid, task_t *task) { + if (!task) { + return KERN_INVALID_ARGUMENT; + } + return task_for_pid(mach_task_self(), pid, task); +} + +static bool basename_matches(const char *path, const char *basename) { + if (!path || !basename) { + return false; + } + const char *tail = strrchr(path, '/'); + tail = tail ? tail + 1 : path; + return strcmp(tail, basename) == 0; +} + +static kern_return_t resolve_remote_image_header(task_t task, const char *image_name, mach_vm_address_t *load_address) { + task_dyld_info_data_t dyld_info; + mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT; + kern_return_t kr = task_info(task, TASK_DYLD_INFO, (task_info_t)&dyld_info, &count); + if (kr != KERN_SUCCESS) { + return kr; + } + + struct dyld_all_image_infos infos; + kr = remote_read_exact(task, dyld_info.all_image_info_addr, &infos, sizeof(infos)); + if (kr != KERN_SUCCESS) { + return kr; + } + if (infos.infoArrayCount == 0 || infos.infoArray == 0) { + return KERN_FAILURE; + } + + size_t infos_size = sizeof(struct dyld_image_info) * infos.infoArrayCount; + struct dyld_image_info *image_infos = calloc(infos.infoArrayCount, sizeof(struct dyld_image_info)); + if (!image_infos) { + return KERN_RESOURCE_SHORTAGE; + } + kr = remote_read_exact(task, (mach_vm_address_t)infos.infoArray, image_infos, infos_size); + if (kr != KERN_SUCCESS) { + free(image_infos); + return kr; + } + + char path_buffer[REMOTE_PATH_LIMIT]; + for (uint32_t index = 0; index < infos.infoArrayCount; index++) { + memset(path_buffer, 0, sizeof(path_buffer)); + if (image_infos[index].imageFilePath == NULL) { + continue; + } + kr = remote_read_exact(task, (mach_vm_address_t)image_infos[index].imageFilePath, path_buffer, sizeof(path_buffer) - 1); + if (kr != KERN_SUCCESS) { + continue; + } + path_buffer[sizeof(path_buffer) - 1] = '\0'; + if (basename_matches(path_buffer, image_name)) { + *load_address = (mach_vm_address_t)image_infos[index].imageLoadAddress; + free(image_infos); + return KERN_SUCCESS; + } + } + free(image_infos); + return KERN_FAILURE; +} + +static kern_return_t compute_breakpoint_address(task_t task, const char *image_name, uint64_t file_stub_address, mach_vm_address_t *out_address) { + mach_vm_address_t image_header = 0; + kern_return_t kr = resolve_remote_image_header(task, image_name, &image_header); + if (kr != KERN_SUCCESS) { + return kr; + } + + struct mach_header_64 header; + kr = remote_read_exact(task, image_header, &header, sizeof(header)); + if (kr != KERN_SUCCESS) { + return kr; + } + if (header.magic != MH_MAGIC_64) { + return KERN_FAILURE; + } + + size_t commands_size = header.sizeofcmds; + uint8_t *commands = calloc(1, commands_size); + if (!commands) { + return KERN_RESOURCE_SHORTAGE; + } + kr = remote_read_exact(task, image_header + sizeof(header), commands, commands_size); + if (kr != KERN_SUCCESS) { + free(commands); + return kr; + } + + uint64_t text_vmaddr = 0; + size_t offset = 0; + for (uint32_t index = 0; index < header.ncmds && offset + sizeof(struct load_command) <= commands_size; index++) { + struct load_command *load_command = (struct load_command *)(commands + offset); + if (load_command->cmd == LC_SEGMENT_64 && offset + sizeof(struct segment_command_64) <= commands_size) { + struct segment_command_64 *segment = (struct segment_command_64 *)(commands + offset); + if (strncmp(segment->segname, "__TEXT", sizeof(segment->segname)) == 0) { + text_vmaddr = segment->vmaddr; + break; + } + } + if (load_command->cmdsize == 0) { + break; + } + offset += load_command->cmdsize; + } + free(commands); + if (text_vmaddr == 0) { + return KERN_FAILURE; + } + uint64_t slide = image_header - text_vmaddr; + *out_address = (mach_vm_address_t)(slide + file_stub_address + 8); + return KERN_SUCCESS; +} + +static kern_return_t resolve_breakpoint_address(task_t task, const options_t *options, mach_vm_address_t *out_address) { + if (options->breakpoint_address > 0) { + *out_address = (mach_vm_address_t)options->breakpoint_address; + return KERN_SUCCESS; + } + return compute_breakpoint_address(task, options->image_name, options->stub_file_address, out_address); +} + +static kern_return_t read_instruction(task_t task, mach_vm_address_t address, uint32_t *instruction) { + return remote_read_exact(task, address, instruction, sizeof(*instruction)); +} + +static void release_debug_threads(void) { + if (g_ctx.debug_threads != NULL) { + for (mach_msg_type_number_t index = 0; index < g_ctx.debug_thread_count; index++) { + if (g_ctx.debug_threads[index] != MACH_PORT_NULL) { + (void)mach_port_deallocate(mach_task_self(), g_ctx.debug_threads[index]); + } + } + (void)mach_vm_deallocate( + mach_task_self(), + (mach_vm_address_t)g_ctx.debug_threads, + (mach_vm_size_t)(sizeof(thread_t) * g_ctx.debug_thread_count) + ); + g_ctx.debug_threads = NULL; + } + free(g_ctx.saved_debug_states); + g_ctx.saved_debug_states = NULL; + g_ctx.debug_thread_count = 0; + g_ctx.hw_breakpoints_installed = false; +} + +static kern_return_t restore_hardware_breakpoints(void) { + if (g_ctx.debug_threads == NULL || g_ctx.saved_debug_states == NULL) { + release_debug_threads(); + return KERN_SUCCESS; + } + kern_return_t first_error = KERN_SUCCESS; + for (mach_msg_type_number_t index = 0; index < g_ctx.debug_thread_count; index++) { + thread_t thread = g_ctx.debug_threads[index]; + if (thread == MACH_PORT_NULL) { + continue; + } + kern_return_t kr = thread_set_state( + thread, + ARM_DEBUG_STATE64, + (thread_state_t)&g_ctx.saved_debug_states[index], + ARM_DEBUG_STATE64_COUNT + ); + if ( + kr != KERN_SUCCESS && + kr != KERN_TERMINATED && + kr != MACH_SEND_INVALID_DEST && + first_error == KERN_SUCCESS + ) { + first_error = kr; + } + } + release_debug_threads(); + return first_error; +} + +static kern_return_t install_hardware_breakpoints(task_t task) { + thread_act_array_t threads = NULL; + mach_msg_type_number_t thread_count = 0; + kern_return_t kr = task_threads(task, &threads, &thread_count); + if (kr != KERN_SUCCESS) { + return kr; + } + if (thread_count == 0) { + if (threads != NULL) { + (void)mach_vm_deallocate(mach_task_self(), (mach_vm_address_t)threads, 0); + } + return KERN_FAILURE; + } + + arm_debug_state64_t *saved_states = calloc(thread_count, sizeof(*saved_states)); + if (!saved_states) { + for (mach_msg_type_number_t index = 0; index < thread_count; index++) { + if (threads[index] != MACH_PORT_NULL) { + (void)mach_port_deallocate(mach_task_self(), threads[index]); + } + } + (void)mach_vm_deallocate( + mach_task_self(), + (mach_vm_address_t)threads, + (mach_vm_size_t)(sizeof(thread_t) * thread_count) + ); + return KERN_RESOURCE_SHORTAGE; + } + + g_ctx.debug_threads = threads; + g_ctx.debug_thread_count = thread_count; + g_ctx.saved_debug_states = saved_states; + + const uint64_t control = HW_BREAKPOINT_CONTROL_EXEC_4BYTES; + const uint64_t address = ((uint64_t)g_ctx.breakpoint_address) & ~0x3ull; + + for (mach_msg_type_number_t index = 0; index < thread_count; index++) { + thread_t thread = threads[index]; + if (thread == MACH_PORT_NULL) { + continue; + } + arm_debug_state64_t debug_state; + memset(&debug_state, 0, sizeof(debug_state)); + mach_msg_type_number_t count = ARM_DEBUG_STATE64_COUNT; + kr = thread_get_state(thread, ARM_DEBUG_STATE64, (thread_state_t)&debug_state, &count); + if (kr == KERN_TERMINATED || kr == MACH_SEND_INVALID_DEST) { + continue; + } + if (kr != KERN_SUCCESS) { + (void)restore_hardware_breakpoints(); + return kr; + } + saved_states[index] = debug_state; + + bool installed = false; + for (size_t slot = 0; slot < MAX_HW_BREAKPOINTS; slot++) { + if ((debug_state.__bcr[slot] & 0x1ull) != 0) { + continue; + } + debug_state.__bvr[slot] = address; + debug_state.__bcr[slot] = control; + installed = true; + break; + } + if (!installed) { + (void)restore_hardware_breakpoints(); + return KERN_NO_SPACE; + } + kr = thread_set_state(thread, ARM_DEBUG_STATE64, (thread_state_t)&debug_state, ARM_DEBUG_STATE64_COUNT); + if (kr == KERN_TERMINATED || kr == MACH_SEND_INVALID_DEST) { + continue; + } + if (kr != KERN_SUCCESS) { + (void)restore_hardware_breakpoints(); + return kr; + } + } + + g_ctx.hw_breakpoints_installed = true; + return KERN_SUCCESS; +} + +static bool thread_set_matches(thread_act_array_t threads, mach_msg_type_number_t count) { + if (!threads || count != g_ctx.debug_thread_count || !g_ctx.debug_threads) { + return false; + } + for (mach_msg_type_number_t index = 0; index < count; index++) { + bool found = false; + for (mach_msg_type_number_t existing = 0; existing < g_ctx.debug_thread_count; existing++) { + if (threads[index] == g_ctx.debug_threads[existing]) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +static void deallocate_thread_list(thread_act_array_t threads, mach_msg_type_number_t count) { + if (!threads) { + return; + } + for (mach_msg_type_number_t index = 0; index < count; index++) { + if (threads[index] != MACH_PORT_NULL) { + (void)mach_port_deallocate(mach_task_self(), threads[index]); + } + } + (void)mach_vm_deallocate( + mach_task_self(), + (mach_vm_address_t)threads, + (mach_vm_size_t)(sizeof(thread_t) * count) + ); +} + +/* + * ARM hardware breakpoints are per-thread. WeChat creates its WCDB/login + * worker threads lazily, after the initial attach, so installing once at + * startup misses the actual PBKDF call. Refresh the breakpoint set when the + * task's thread set changes; the short suspend window prevents a new thread + * from running without the breakpoint. + */ +static kern_return_t refresh_hardware_breakpoints_if_needed(void) { + thread_act_array_t threads = NULL; + mach_msg_type_number_t count = 0; + kern_return_t kr = task_threads(g_ctx.task, &threads, &count); + if (kr != KERN_SUCCESS) { + return kr; + } + bool changed = !thread_set_matches(threads, count); + if (!changed) { + deallocate_thread_list(threads, count); + return KERN_SUCCESS; + } + deallocate_thread_list(threads, count); + + kr = task_suspend(g_ctx.task); + if (kr != KERN_SUCCESS) { + return kr; + } + (void)restore_hardware_breakpoints(); + kr = install_hardware_breakpoints(g_ctx.task); + (void)task_resume(g_ctx.task); + return kr; +} + +static kern_return_t install_exception_port(task_t task) { + g_ctx.old_count = MAX_EXCEPTION_PORTS; + kern_return_t kr = task_get_exception_ports( + task, + EXC_MASK_BREAKPOINT, + g_ctx.masks, + &g_ctx.old_count, + g_ctx.old_ports, + g_ctx.old_behaviors, + g_ctx.old_flavors + ); + if (kr != KERN_SUCCESS) { + return kr; + } + kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &g_ctx.exception_port); + if (kr != KERN_SUCCESS) { + return kr; + } + kr = mach_port_insert_right(mach_task_self(), g_ctx.exception_port, g_ctx.exception_port, MACH_MSG_TYPE_MAKE_SEND); + if (kr != KERN_SUCCESS) { + return kr; + } + /* + * This helper is linked with the legacy exc_server MIG dispatcher and + * implements catch_exception_raise(), whose exception codes are the + * exception_data_t (32-bit) form. MACH_EXCEPTION_CODES changes the wire + * protocol to mach_exception_raise() with 64-bit codes; feeding that + * message to exc_server leaves the breakpoint request unanswered and the + * WeChat login thread suspended forever at "正在进入". Keep both sides on + * the EXCEPTION_DEFAULT/exc_server protocol. + */ + return task_set_exception_ports( + task, + EXC_MASK_BREAKPOINT, + g_ctx.exception_port, + EXCEPTION_DEFAULT, + THREAD_STATE_NONE + ); +} + +static void restore_exception_ports(task_t task) { + for (mach_msg_type_number_t index = 0; index < g_ctx.old_count; index++) { + if (g_ctx.masks[index] == 0) { + continue; + } + (void)task_set_exception_ports(task, g_ctx.masks[index], g_ctx.old_ports[index], g_ctx.old_behaviors[index], g_ctx.old_flavors[index]); + } + if (g_ctx.exception_port != MACH_PORT_NULL) { + mach_port_mod_refs(mach_task_self(), g_ctx.exception_port, MACH_PORT_RIGHT_RECEIVE, -1); + g_ctx.exception_port = MACH_PORT_NULL; + } +} + +static kern_return_t continue_thread_at_x16(thread_t thread) { + arm_thread_state64_t state; + mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT; + kern_return_t kr = thread_get_state(thread, ARM_THREAD_STATE64, (thread_state_t)&state, &count); + if (kr != KERN_SUCCESS) { + return kr; + } + arm_thread_state64_ptrauth_strip(state); + void (*next_pc)(void) = (void (*)(void))(uintptr_t)state.__x[16]; + arm_thread_state64_set_pc_fptr(state, next_pc); + return thread_set_state(thread, ARM_THREAD_STATE64, (thread_state_t)&state, ARM_THREAD_STATE64_COUNT); +} + +static kern_return_t handle_breakpoint(thread_t thread) { + arm_thread_state64_t state; + mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT; + kern_return_t kr = thread_get_state(thread, ARM_THREAD_STATE64, (thread_state_t)&state, &count); + if (kr != KERN_SUCCESS) { + return kr; + } + arm_thread_state64_ptrauth_strip(state); + uintptr_t pc = arm_thread_state64_get_pc(state); + uintptr_t expected = (uintptr_t)g_ctx.breakpoint_address; + if (pc != expected && pc != expected + 4) { + return continue_thread_at_x16(thread); + } + + g_ctx.pbkdf_calls += 1; + if (!breakpoint_operands_match(&state) || !g_ctx.page1_loaded) { + return continue_thread_at_x16(thread); + } + + uint8_t salt[SALT_SIZE]; + uint8_t candidate[KEY_SIZE]; + kr = remote_read_exact(g_ctx.task, (mach_vm_address_t)state.__x[3], salt, sizeof(salt)); + if (kr != KERN_SUCCESS) { + return continue_thread_at_x16(thread); + } + if (!salt_matches_expected(state.__x[6], salt)) { + return continue_thread_at_x16(thread); + } + kr = remote_read_exact(g_ctx.task, (mach_vm_address_t)state.__x[1], candidate, sizeof(candidate)); + if (kr != KERN_SUCCESS) { + return continue_thread_at_x16(thread); + } + if (candidate_matches_page1(candidate)) { + bytes_to_hex(candidate, KEY_SIZE, g_ctx.db_key_hex); + g_ctx.captured = true; + g_ctx.validated = true; + g_ctx.stop_requested = 1; + /* + * The candidate has already passed the encrypted page-1 HMAC check. + * This is a disposable, temporarily re-signed WeChat process which the + * transactional wrapper must close before restoring the official app. + * Do not resume the thread from this hardware-breakpoint exception: + * macOS 27 can redeliver the pending SIGTRAP after the exception port + * is removed, producing a crash report despite a successful capture. + * Terminate the temporary process while it is still exception-stopped; + * SIGKILL does not become an application crash and cannot leak the + * breakpoint exception back into WeChat. + */ + if (g_ctx.target_pid > 0) { + (void)kill(g_ctx.target_pid, SIGKILL); + } + return KERN_SUCCESS; + } + return continue_thread_at_x16(thread); +} + +kern_return_t catch_exception_raise( + mach_port_t exception_port, + mach_port_t thread, + mach_port_t task, + exception_type_t exception, + exception_data_t code, + mach_msg_type_number_t code_count +) { + (void)exception_port; + (void)task; + (void)code; + (void)code_count; + if (exception != EXC_BREAKPOINT) { + return KERN_FAILURE; + } + return handle_breakpoint(thread); +} + +kern_return_t catch_exception_raise_state( + mach_port_t exception_port, + exception_type_t exception, + const exception_data_t code, + mach_msg_type_number_t code_count, + int *flavor, + const thread_state_t old_state, + mach_msg_type_number_t old_state_count, + thread_state_t new_state, + mach_msg_type_number_t *new_state_count +) { + (void)exception_port; + (void)exception; + (void)code; + (void)code_count; + (void)flavor; + (void)old_state; + (void)old_state_count; + (void)new_state; + (void)new_state_count; + return KERN_FAILURE; +} + +kern_return_t catch_exception_raise_state_identity( + mach_port_t exception_port, + mach_port_t thread, + mach_port_t task, + exception_type_t exception, + exception_data_t code, + mach_msg_type_number_t code_count, + int *flavor, + thread_state_t old_state, + mach_msg_type_number_t old_state_count, + thread_state_t new_state, + mach_msg_type_number_t *new_state_count +) { + (void)exception_port; + (void)thread; + (void)task; + (void)exception; + (void)code; + (void)code_count; + (void)flavor; + (void)old_state; + (void)old_state_count; + (void)new_state; + (void)new_state_count; + return KERN_FAILURE; +} + +static kern_return_t wait_for_breakpoint(int timeout_seconds) { + union { + mach_msg_header_t head; + union __RequestUnion__exc_subsystem request; + } request; + union { + mach_msg_header_t head; + union __ReplyUnion__exc_subsystem reply; + } reply; + + struct timeval start; + gettimeofday(&start, NULL); + struct timeval last_refresh = start; + while (!g_ctx.stop_requested) { + struct timeval now; + gettimeofday(&now, NULL); + if ((now.tv_sec - start.tv_sec) >= timeout_seconds) { + return KERN_OPERATION_TIMED_OUT; + } + + if ((now.tv_sec - last_refresh.tv_sec) >= 1) { + kern_return_t refresh_kr = refresh_hardware_breakpoints_if_needed(); + if (refresh_kr == KERN_TERMINATED || refresh_kr == MACH_SEND_INVALID_DEST) { + return refresh_kr; + } + if (refresh_kr != KERN_SUCCESS) { + return refresh_kr; + } + last_refresh = now; + } + + kern_return_t kr = mach_msg( + &request.head, + MACH_RCV_MSG | MACH_RCV_TIMEOUT, + 0, + sizeof(request), + g_ctx.exception_port, + 1000, + MACH_PORT_NULL + ); + if (kr == MACH_RCV_TIMED_OUT) { + continue; + } + if (kr != KERN_SUCCESS) { + return kr; + } + if (!exc_server(&request.head, &reply.head)) { + continue; + } + kr = mach_msg(&reply.head, MACH_SEND_MSG, reply.head.msgh_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); + if (kr != KERN_SUCCESS) { + if (g_ctx.captured && (kr == MACH_SEND_INVALID_DEST || kr == KERN_TERMINATED)) { + return KERN_SUCCESS; + } + return kr; + } + } + return g_ctx.captured ? KERN_SUCCESS : KERN_ABORTED; +} + +static int run_preflight(const options_t *options) { + memset(&g_ctx, 0, sizeof(g_ctx)); + kern_return_t kr = attach_task(options->pid, &g_ctx.task); + if (kr != KERN_SUCCESS) { + json_error("native_attach_failed", "task_for_pid failed"); + return 1; + } + kr = resolve_breakpoint_address(g_ctx.task, options, &g_ctx.breakpoint_address); + if (kr != KERN_SUCCESS) { + json_error("native_image_not_found", "wechat.dylib not found in target task"); + return 1; + } + uint32_t instruction = 0; + kr = read_instruction(g_ctx.task, g_ctx.breakpoint_address, &instruction); + if (kr != KERN_SUCCESS) { + json_error("native_breakpoint_read_failed", "cannot read PBKDF stub instruction"); + return 1; + } + if (instruction != BR_X16_INSN) { + json_error("native_breakpoint_shape_mismatch", "PBKDF stub layout changed"); + return 1; + } + task_suspend(g_ctx.task); + kr = install_hardware_breakpoints(g_ctx.task); + if (kr == KERN_SUCCESS) { + (void)restore_hardware_breakpoints(); + } + task_resume(g_ctx.task); + if (kr != KERN_SUCCESS) { + json_error("native_breakpoint_install_failed", "cannot install hardware breakpoint"); + return 1; + } + json_success_preflight(options); + return 0; +} + +static int run_capture(const options_t *options) { + memset(&g_ctx, 0, sizeof(g_ctx)); + g_ctx.target_pid = options->pid; + install_signal_handlers(); + const char *page1_source = options->page1_path[0] ? options->page1_path : options->database_path; + if (!read_page1(page1_source, g_ctx.page1)) { + json_error("native_probe_database_unreadable", "cannot read encrypted page1"); + return 1; + } + g_ctx.page1_loaded = true; + + kern_return_t kr = attach_task(options->pid, &g_ctx.task); + if (kr != KERN_SUCCESS) { + json_error("native_attach_failed", "task_for_pid failed"); + return 1; + } + kr = resolve_breakpoint_address(g_ctx.task, options, &g_ctx.breakpoint_address); + if (kr != KERN_SUCCESS) { + json_error("native_image_not_found", "wechat.dylib not found in target task"); + return 1; + } + kr = read_instruction(g_ctx.task, g_ctx.breakpoint_address, &g_ctx.original_instruction); + if (kr != KERN_SUCCESS) { + json_error("native_breakpoint_read_failed", "cannot read PBKDF stub instruction"); + return 1; + } + if (g_ctx.original_instruction != BR_X16_INSN) { + json_error("native_breakpoint_shape_mismatch", "PBKDF stub layout changed"); + return 1; + } + kr = install_exception_port(g_ctx.task); + if (kr != KERN_SUCCESS) { + json_error("native_exception_port_failed", "cannot install breakpoint exception port"); + return 1; + } + + task_suspend(g_ctx.task); + kr = install_hardware_breakpoints(g_ctx.task); + task_resume(g_ctx.task); + if (kr != KERN_SUCCESS) { + restore_exception_ports(g_ctx.task); + json_error("native_breakpoint_install_failed", "cannot install hardware breakpoint"); + return 1; + } + if (!write_ready_file(options->ready_file, options->pid)) { + task_suspend(g_ctx.task); + (void)restore_hardware_breakpoints(); + restore_exception_ports(g_ctx.task); + (void)task_resume(g_ctx.task); + json_error("native_ready_signal_failed", "cannot write monitor readiness signal"); + return 1; + } + + kr = wait_for_breakpoint(options->timeout_seconds); + + if (g_ctx.captured) { + /* The successful path intentionally terminated the disposable task. */ + (void)restore_hardware_breakpoints(); + restore_exception_ports(g_ctx.task); + } else { + kern_return_t suspend_kr = task_suspend(g_ctx.task); + (void)restore_hardware_breakpoints(); + restore_exception_ports(g_ctx.task); + if (suspend_kr == KERN_SUCCESS) { + (void)task_resume(g_ctx.task); + } + } + + if (kr == KERN_OPERATION_TIMED_OUT) { + json_error("native_capture_timeout", "timed out waiting for login-triggered PBKDF"); + return 1; + } + if (kr != KERN_SUCCESS || !g_ctx.validated) { + json_error("native_capture_unvalidated", "captured candidate did not validate against page1"); + return 1; + } + json_success_capture(options); + return 0; +} + +int main(int argc, char **argv) { + options_t options; + if (!parse_args(argc, argv, &options)) { + json_error("native_invalid_arguments", "invalid arguments"); + return 1; + } + if (streq(options.mode, "preflight")) { + return run_preflight(&options); + } + return run_capture(&options); +} diff --git a/src/wechat_decrypt_tool/platform_support.py b/src/wechat_decrypt_tool/platform_support.py index a7522ee2..32c0d871 100644 --- a/src/wechat_decrypt_tool/platform_support.py +++ b/src/wechat_decrypt_tool/platform_support.py @@ -186,7 +186,10 @@ def runtime_capabilities() -> dict[str, Any]: and native_core_paths and _native_core_resources_ready(native_core_paths) ) - macos_lldb_fallback = bool(apple_silicon and shutil.which("lldb")) + # Keep the response field name for frontend compatibility. The current + # implementation compiles a small native Mach exception helper and only + # requires the Command Line Tools toolchain, not an LLDB subprocess. + macos_lldb_fallback = bool(apple_silicon and shutil.which("xcrun")) mac_db_key_status: dict[str, Any] = { "available": False, "note": MAC_DB_KEY_GUIDANCE, @@ -209,7 +212,7 @@ def runtime_capabilities() -> dict[str, Any]: "database_key_extraction": system == "windows" or bool(mac_db_key_status["available"]), "macos_lldb_fallback": macos_lldb_fallback, "macos_lldb_fallback_note": ( - "实验性 LLDB 兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。" + "实验性本机调试兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。" if system == "macos" and not macos_lldb_fallback else "" ), diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py index ba960833..3922349a 100644 --- a/src/wechat_decrypt_tool/routers/keys.py +++ b/src/wechat_decrypt_tool/routers/keys.py @@ -109,7 +109,7 @@ def _macos_capture_error_response(error: BaseException, *, stage: str) -> dict: known = isinstance(error, MacOSDBKeyCaptureFailure) code = str(getattr(error, "code", "INTERNAL_ERROR") or "INTERNAL_ERROR") if known else "INTERNAL_ERROR" logger.warning( - "[keys] experimental macOS LLDB capture failed: stage=%s error_code=%s classified=%s", + "[keys] experimental macOS local capture failed: stage=%s error_code=%s classified=%s", stage, code, known, @@ -120,10 +120,10 @@ def _macos_capture_error_response(error: BaseException, *, stage: str) -> dict: needs_cleanup = bool(getattr(error, "wechat_modified", False)) if known else False return { "status": -1, - "errmsg": str(error).strip() if known else "实验性 LLDB 密钥获取失败,已停止本次操作。", + "errmsg": str(error).strip() if known else "实验性本机调试密钥获取失败,已停止本次操作。", "data": { "platform": "macos", - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": stage, "error_code": code, "wechat_modified": bool(getattr(error, "wechat_modified", False)) if known else False, @@ -138,7 +138,7 @@ def _macos_capture_busy_response() -> dict: "errmsg": "另一项 macOS 密钥操作仍在进行,请等待完成或先停止当前操作。", "data": { "platform": "macos", - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": _current_macos_capture_operation() or "busy", "error_code": "CAPTURE_BUSY", }, @@ -164,10 +164,10 @@ def _macos_capture_local_request_error(request: Request) -> dict | None: return None return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获只能在运行 WCDA 的 Mac 本机界面操作。", + "errmsg": "实验性本机密钥捕获只能在运行 WCDA 的 Mac 本机界面操作。", "data": { "platform": current_platform(), - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "error_code": "REMOTE_CLIENT_FORBIDDEN", }, } @@ -630,14 +630,14 @@ async def watch_disconnect() -> None: } -@router.get("/api/macos-key-capture/status", summary="查看实验性 macOS LLDB 密钥捕获状态") +@router.get("/api/macos-key-capture/status", summary="查看实验性 macOS 本机密钥捕获状态") async def get_macos_key_capture_status(request: Request): if local_error := _macos_capture_local_request_error(request): return local_error if not is_macos(): return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "errmsg": "实验性本机密钥捕获仅支持 macOS。", "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, } persisted = get_in_place_capture_status() @@ -646,26 +646,26 @@ async def get_macos_key_capture_status(request: Request): "errmsg": "ok", "data": { **persisted, - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "active_operation": _current_macos_capture_operation(), }, } -@router.post("/api/macos-key-capture/prepare", summary="准备实验性 macOS LLDB 密钥捕获") +@router.post("/api/macos-key-capture/prepare", summary="准备实验性 macOS 本机密钥捕获") async def prepare_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): if local_error := _macos_capture_local_request_error(request): return local_error if not is_macos(): return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "errmsg": "实验性本机密钥捕获仅支持 macOS。", "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, } if not _macos_lldb_fallback_available(): return { "status": -1, - "errmsg": "当前 Mac 不满足实验性 LLDB 兜底条件,请确认使用 Apple Silicon 并安装 Xcode Command Line Tools。", + "errmsg": "当前 Mac 不满足实验性本机调试兜底条件,请确认使用 Apple Silicon 并安装 Xcode Command Line Tools。", "data": {"platform": "macos", "error_code": "LLDB_FALLBACK_UNAVAILABLE"}, } operation = "prepare" @@ -681,7 +681,7 @@ async def prepare_macos_key_capture(request: Request, payload: MacosKeyCaptureRe "status": 0, "errmsg": "ok", "data": { - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": "prepared", "wechat_modified": bool(result.get("wechat_modified", True)), "ready_for_preflight": bool(result.get("ready_for_preflight", True)), @@ -693,14 +693,14 @@ async def prepare_macos_key_capture(request: Request, payload: MacosKeyCaptureRe _end_macos_capture_operation(operation) -@router.post("/api/macos-key-capture/preflight", summary="预检实验性 macOS LLDB 断点") +@router.post("/api/macos-key-capture/preflight", summary="预检实验性 macOS 本机捕获点") async def preflight_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): if local_error := _macos_capture_local_request_error(request): return local_error if not is_macos(): return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "errmsg": "实验性本机密钥捕获仅支持 macOS。", "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, } operation = "preflight" @@ -716,7 +716,7 @@ async def preflight_macos_key_capture(request: Request, payload: MacosKeyCapture "status": 0, "errmsg": "ok", "data": { - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": "preflight_passed", "wechat_modified": bool(result.get("wechat_modified", True)), "process_attached": bool(result.get("process_attached", False)), @@ -728,14 +728,14 @@ async def preflight_macos_key_capture(request: Request, payload: MacosKeyCapture _end_macos_capture_operation(operation) -@router.post("/api/macos-key-capture/capture", summary="执行实验性 macOS LLDB 密钥捕获") +@router.post("/api/macos-key-capture/capture", summary="执行实验性 macOS 本机密钥捕获") async def capture_macos_key(request: Request, payload: MacosKeyCaptureRequest): if local_error := _macos_capture_local_request_error(request): return local_error if not is_macos(): return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "errmsg": "实验性本机密钥捕获仅支持 macOS。", "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, } operation = "capture" @@ -779,8 +779,10 @@ async def capture_macos_key(request: Request, payload: MacosKeyCaptureRequest): "status": 0, "errmsg": "ok", "data": { - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": "completed", + "validated": True, + "key_saved": True, "db_key": db_key, "wechat_modified": False, "official_wechat_verified": bool(result.get("official_wechat_verified")), @@ -793,14 +795,14 @@ async def capture_macos_key(request: Request, payload: MacosKeyCaptureRequest): _end_macos_capture_operation(operation) -@router.post("/api/macos-key-capture/cancel", summary="停止并清理实验性 macOS LLDB 密钥捕获") +@router.post("/api/macos-key-capture/cancel", summary="停止并清理实验性 macOS 本机密钥捕获") async def cancel_macos_key_capture(request: Request, payload: MacosKeyCaptureRequest): if local_error := _macos_capture_local_request_error(request): return local_error if not is_macos(): return { "status": -1, - "errmsg": "实验性 LLDB 密钥捕获仅支持 macOS。", + "errmsg": "实验性本机密钥捕获仅支持 macOS。", "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"}, } if not _macos_capture_cancel_lock.acquire(blocking=False): @@ -826,7 +828,7 @@ async def cancel_macos_key_capture(request: Request, payload: MacosKeyCaptureReq "status": 0, "errmsg": "ok", "data": { - "method": "macos_inplace_lldb", + "method": "macos_inplace_native", "stage": "cancelled", "pending": False, "wechat_modified": False, diff --git a/tests/test_decrypt_image_keys_frontend.py b/tests/test_decrypt_image_keys_frontend.py index 23dcf54d..e887f998 100644 --- a/tests/test_decrypt_image_keys_frontend.py +++ b/tests/test_decrypt_image_keys_frontend.py @@ -112,6 +112,13 @@ def test_database_key_action_waits_for_platform_detection(): assert "!platformCapabilitiesLoaded ? '正在检测系统'" in source +def test_macos_database_path_hint_allows_custom_account_directory_names(): + source = read_decrypt_page() + + assert "<账号目录>/db_storage" in source + assert "账号目录可能是 wxid_... 或自定义名称" in source + + def test_macos_database_key_uses_helper_first_and_explicit_lldb_fallback(): source = read_decrypt_page() @@ -122,7 +129,7 @@ def test_macos_database_key_uses_helper_first_and_explicit_lldb_fallback(): assert "完整退出微信程序" in source assert "仅退出当前账号" not in source assert "数据库解密密钥已通过 macOS 本地受控组件获取成功" in source - assert "受控组件失败,是否改用 LLDB 兜底" in source + assert "受控组件失败,是否改用本机调试兜底" in source assert "临时重签和调试可能触发微信安全提醒" in source assert "prepareMacosKeyCapture" in source assert "preflightMacosKeyCapture" in source @@ -142,12 +149,29 @@ def test_macos_lldb_fallback_is_staged_and_recovers_pending_state(): assert "步骤 2 / 3" in source assert "已看到二维码,开始监测" in source assert "getMacosKeyCaptureStatus" in source + assert "statusResponse?.data?.monitor_ready === true" in source + assert "captureOutcomePromise" in source + assert "显示“监测已就绪”前请不要登录微信" in source assert "检测到上次未完成的临时调试微信" in source assert "if (macosKeyCaptureOwnedByPage.value) void cleanupMacosKeyCapture" in source for action in ("prepare", "preflight", "capture", "cancel"): assert f"macosKeyCaptureRequest('{action}'" in api_source +def test_macos_fallback_displays_only_a_validated_local_key() -> None: + source = read_decrypt_page() + handler = source.index("const runMacosLldbFallback = async") + end = source.index("const handleGetDbKey = async", handler) + fallback = source[handler:end] + + assert "response?.data?.db_key" in fallback + assert "response?.data?.validated === true" in fallback + assert "response?.data?.key_saved === true" in fallback + assert "formData.key = key" in fallback + assert "/^[0-9a-f]{64}$/.test(key)" in fallback + assert "获取接口仅允许本机访问" in source + + def test_macos_database_key_clears_prefill_and_always_starts_real_capture(): source = read_decrypt_page() diff --git a/tests/test_macos_clone_capture.py b/tests/test_macos_clone_capture.py index e6e94347..7e0c50b5 100644 --- a/tests/test_macos_clone_capture.py +++ b/tests/test_macos_clone_capture.py @@ -377,6 +377,8 @@ def __exit__(self, exc_type, exc, traceback): def report_exit(_command, *, timeout): self.assertEqual(timeout, 285.0) + command_source = (root / "capture.lldb").read_text(encoding="utf-8") + self.assertIn("process handle SIGTRAP -n false -p false -s false", command_source) (root / "result.json").write_text( json.dumps( { diff --git a/tests/test_macos_inplace_capture.py b/tests/test_macos_inplace_capture.py index 5888d195..4f4ae224 100644 --- a/tests/test_macos_inplace_capture.py +++ b/tests/test_macos_inplace_capture.py @@ -16,6 +16,7 @@ _write_state, capture_prepared_in_place, cleanup_in_place_capture, + native_capture_monitor_ready, recover_stale_in_place_capture, ) @@ -110,7 +111,7 @@ def test_capture_failure_always_requests_restore(self) -> None: patch("wechat_decrypt_tool.macos_inplace_capture._restore_after_terminal_path") as restore, ): with self.assertRaises(MacOSDBKeyCaptureFailure): - capture_prepared_in_place( + result = capture_prepared_in_place( official, backup_root=Path("/Volumes/BackupVolume/backups"), probe_db_path=Path("/tmp/message_0.db"), @@ -118,13 +119,25 @@ def test_capture_failure_always_requests_restore(self) -> None: ) restore.assert_called_once() - def test_capture_arms_resolved_internal_return_fallback(self) -> None: + def test_native_monitor_ready_requires_valid_private_status(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + debug_root = Path(temp_dir) + ready = debug_root / "native-capture-ready.json" + ready.write_text( + json.dumps({"status": "ready", "method": "macos_native_mach", "pid": 321}), + encoding="utf-8", + ) + self.assertTrue(native_capture_monitor_ready(debug_root=debug_root)) + ready.write_text(json.dumps({"status": "ready", "pid": 321}), encoding="utf-8") + self.assertFalse(native_capture_monitor_ready(debug_root=debug_root)) + + def test_capture_uses_native_monitor_and_removes_probe_copy(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: debug_root = Path(temp_dir) probe = debug_root / "message_0.db" probe.write_bytes(bytes(range(256)) * 16) (debug_root / "breakpoint-preflight.json").write_text( - json.dumps({"pid": 321, "pbkdf_locations": 1, "key_return_locations": 1}), + json.dumps({"pid": 222, "stub_file_address": 4096}), encoding="utf-8", ) with ( @@ -136,9 +149,10 @@ def test_capture_arms_resolved_internal_return_fallback(self) -> None: "wechat_decrypt_tool.macos_inplace_capture._require_prepared_process", return_value=({}, 321), ), + patch("wechat_decrypt_tool.macos_inplace_capture._candidate_bundle_pids", return_value=[654, 321]), patch( - "wechat_decrypt_tool.macos_inplace_capture.capture_salt_matched_passphrase", - return_value="ab" * 32, + "wechat_decrypt_tool.macos_inplace_capture.capture_native_wcdb_key", + return_value={"db_key": "ab" * 32, "method": "macos_native_mach", "validated": True}, ) as capture, patch("wechat_decrypt_tool.macos_inplace_capture._validate_captured_passphrase"), patch( @@ -150,14 +164,17 @@ def test_capture_arms_resolved_internal_return_fallback(self) -> None: return_value={"official_wechat_verified": True, "official_wechat_restored": True}, ), ): - capture_prepared_in_place( + result = capture_prepared_in_place( "/Applications/WeChat.app", backup_root=debug_root / "backups", probe_db_path=probe, debug_root=debug_root, ) - self.assertTrue(capture.call_args.kwargs["enable_key_return_fallback"]) + self.assertEqual(result["method"], "macos_native_mach") + self.assertEqual(capture.call_args.kwargs["pid"], 654) + self.assertFalse(capture.call_args.kwargs["probe_page1_path"].exists()) + self.assertFalse((debug_root / "native-capture-ready.json").exists()) def test_capture_can_defer_cache_until_full_account_validation(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -165,7 +182,7 @@ def test_capture_can_defer_cache_until_full_account_validation(self) -> None: probe = debug_root / "message_0.db" probe.write_bytes(bytes(range(256)) * 16) (debug_root / "breakpoint-preflight.json").write_text( - json.dumps({"pid": 321, "pbkdf_locations": 1, "key_return_locations": 0}), + json.dumps({"pid": 321, "stub_file_address": 4096}), encoding="utf-8", ) with ( @@ -177,9 +194,10 @@ def test_capture_can_defer_cache_until_full_account_validation(self) -> None: "wechat_decrypt_tool.macos_inplace_capture._require_prepared_process", return_value=({}, 321), ), + patch("wechat_decrypt_tool.macos_inplace_capture._candidate_bundle_pids", return_value=[321]), patch( - "wechat_decrypt_tool.macos_inplace_capture.capture_salt_matched_passphrase", - return_value="ab" * 32, + "wechat_decrypt_tool.macos_inplace_capture.capture_native_wcdb_key", + return_value={"db_key": "ab" * 32, "method": "macos_native_mach", "validated": True}, ), patch("wechat_decrypt_tool.macos_inplace_capture._validate_captured_passphrase"), patch("wechat_decrypt_tool.macos_inplace_capture.save_passphrase") as save, diff --git a/tests/test_macos_native_capture_source.py b/tests/test_macos_native_capture_source.py new file mode 100644 index 00000000..cbd074ee --- /dev/null +++ b/tests/test_macos_native_capture_source.py @@ -0,0 +1,33 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/wechat_decrypt_tool/native/macos/source/wcdb_native_capture.c" + + +def test_validated_capture_terminates_disposable_process_before_returning() -> None: + source = SOURCE.read_text(encoding="utf-8") + start = source.index("if (candidate_matches_page1(candidate))") + end = source.index(" return continue_thread_at_x16(thread);", start) + block = source[start:end] + + assert "kill(g_ctx.target_pid, SIGKILL)" in block + assert "task_resume" not in block + assert "continue_thread_at_x16" not in block + + +def test_monitor_reports_ready_only_after_breakpoint_installation() -> None: + source = SOURCE.read_text(encoding="utf-8") + start = source.index("static int run_capture") + install = source.index("kr = install_hardware_breakpoints(g_ctx.task);", start) + ready = source.index("write_ready_file(options->ready_file, options->pid)", install) + + assert install < ready + + +def test_helper_source_contains_no_environment_specific_paths() -> None: + source = SOURCE.read_text(encoding="utf-8") + + assert "/Users/" not in source + assert "/Volumes/" not in source + assert "wxid_" not in source diff --git a/tests/test_macos_platform_support.py b/tests/test_macos_platform_support.py index f84c9c81..b4090e53 100644 --- a/tests/test_macos_platform_support.py +++ b/tests/test_macos_platform_support.py @@ -288,7 +288,7 @@ def test_macos_lldb_prepare_endpoint_returns_only_sanitized_stage(self) -> None: self.assertNotIn("backup_path", result["data"]) self.assertNotIn("state_path", result["data"]) - def test_macos_lldb_capture_validates_before_caching_and_returning_key(self) -> None: + def test_macos_capture_validates_caches_and_returns_key_to_local_frontend(self) -> None: db_key = "ab" * 32 with ( patch.object(keys_router, "is_macos", return_value=True), @@ -318,6 +318,8 @@ def test_macos_lldb_capture_validates_before_caching_and_returning_key(self) -> ) self.assertEqual(result["status"], 0) + self.assertTrue(result["data"]["validated"]) + self.assertTrue(result["data"]["key_saved"]) self.assertEqual(result["data"]["db_key"], db_key) self.assertNotIn("cache_path", result["data"]) self.assertNotIn("backup_path", result["data"])