+
{{ !platformCapabilitiesLoaded ? '正在检测系统' : (isGettingDbKey ? '获取中...' : '一键获取数据库密钥') }}
+
{{ isMacos
- ? '点击后将调用本地受控组件;显示“获取中”后,请完整退出微信程序,再立即重新打开并登录。WCDA 会自动跟随重启后的微信进程,密钥不会上传。'
+ ? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性本机调试兜底。获取接口仅允许本机访问。'
: '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}
@@ -111,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
@@ -1083,6 +1091,11 @@ const {
saveMediaKeys,
getSavedKeys,
getKeys,
+ getMacosKeyCaptureStatus,
+ prepareMacosKeyCapture,
+ preflightMacosKeyCapture,
+ captureMacosKey,
+ cancelMacosKeyCapture,
getImageKey,
getImageKeyMemory,
getWxStatus,
@@ -1110,6 +1123,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')
@@ -1956,14 +1972,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) => {
@@ -1976,15 +2020,175 @@ const showDbKeyPersistenceWarning = (result) => {
})
}
+const runMacosLldbFallback = async ({ requestRevision, requestController, helperError }) => {
+ const riskAccepted = await requestGuideDialog({
+ eyebrow: '实验性兜底方式',
+ title: '受控组件失败,是否改用本机调试兜底?',
+ 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 = '正在短暂检查本机捕获点,完成后会立即脱离。'
+ 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 || '当前微信版本未通过本机捕获点预检,已停止并恢复。'
+ 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 = '正在等待管理员授权并启动本机监测;显示“监测已就绪”前请不要登录微信。'
+ 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
+ && 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 || '实验性本机调试未能获取可验证的数据库密钥。'
+ 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
}
@@ -1993,14 +2197,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)) {
@@ -2012,12 +2224,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 {
@@ -3171,8 +3397,12 @@ const confirmBackFromRunningStep = () => {
: currentStep.value === 0 && isGettingDbKey.value
? {
title: '数据库密钥仍在获取',
- description: '返回账号选择会停止当前页面等待结果;如果 Hook 已经开始,微信重启或登录流程仍可能继续完成。',
- details: ['页面将不再接收本次密钥结果', '已经启动的 Hook 操作无法保证立即停止']
+ description: isMacos.value
+ ? '返回账号选择会停止当前获取;如果已进入本机调试兜底,系统会同时尝试恢复腾讯官方签名微信。'
+ : '返回账号选择会停止当前页面等待结果;如果 Hook 已经开始,微信重启或登录流程仍可能继续完成。',
+ details: isMacos.value
+ ? ['页面将不再接收本次密钥结果', '恢复完成前请不要手动启动或更新微信']
+ : ['页面将不再接收本次密钥结果', '已经启动的 Hook 操作无法保证立即停止']
}
: currentStep.value === 2 && mediaDecrypting.value
? { title: '图片仍在解密', description: '返回填写图片密钥会停止当前图片解密,已经完成的图片会保留。' }
@@ -3328,6 +3558,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 = '仍有未完成的临时调试微信恢复状态;恢复前不会开始新的本机调试获取。'
+ }
+ } catch (statusError) {
+ logDecryptDebug('macos-key-capture:status-error', { error: formatLogError(statusError) })
+ }
+}
+
// 页面加载时检查是否有选中的账户
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
@@ -3349,6 +3610,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/pyproject.toml b/pyproject.toml
index a1584573..df07bec0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,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
new file mode 100644
index 00000000..8a7bffd0
--- /dev/null
+++ b/src/wechat_decrypt_tool/macos_clone_capture.py
@@ -0,0 +1,1118 @@
+"""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 the KDF profile and salt match a cloned database and
+the candidate passes that database's page-one HMAC verification.
+"""
+
+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)
+ 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
+import json
+import lldb
+import os
+
+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}
+MODULE_NAME = __name__
+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,
+}}
+
+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 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:
+ 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, 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
+ 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")
+ debugger.HandleCommand(f"command script add -f {{MODULE_NAME}}._report_process_exit wedata_capture_exit_report")
+'''
+
+
+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"
+ "process handle SIGTRAP -n false -p false -s false\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"
+ "process handle SIGTRAP -n false -p false -s false\n"
+ f"command script import {callback_path}\n"
+ "wedata_capture\n"
+ "process continue\n"
+ "wedata_capture_exit_report\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_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
+ + "请先在未监测状态下退出到登录界面,再启动监测并重新登录同一账号。",
+ 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..d1a81b81
--- /dev/null
+++ b/src/wechat_decrypt_tool/macos_db_key_capture.py
@@ -0,0 +1,1159 @@
+"""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,
+ save_result: bool = True,
+) -> 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,
+ save_result=save_result,
+ )
+
+
+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..9abe8276
--- /dev/null
+++ b/src/wechat_decrypt_tool/macos_inplace_capture.py
@@ -0,0 +1,747 @@
+"""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 subprocess
+import time
+from pathlib import Path
+from typing import Any
+
+from .macos_clone_capture import (
+ _breakpoint_preflight_path,
+ _remove_breakpoint_preflight,
+)
+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,
+ normalize_wechat_app_path,
+ 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
+
+
+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()
+
+
+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,
+ "monitor_ready": 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,
+ "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)
+ 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 _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:
+ 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)
+ _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):
+ 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_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.
+ _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)
+ _terminate_native_capture_processes(debug_root)
+ 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 _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,
+ *,
+ 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: 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:
+ 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_native_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,
+ save_result: bool = True,
+ debug_root: Path = DEFAULT_DEBUG_ROOT,
+) -> 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:
+ 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}")
+ 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):
+ 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,
+ 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)
+ 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,
+ )
+ 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": 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,
+ "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",
+ "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 76f69b91..32c0d871 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,10 @@ def runtime_capabilities() -> dict[str, Any]:
and native_core_paths
and _native_core_resources_ready(native_core_paths)
)
+ # 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,
@@ -205,6 +210,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": (
+ "实验性本机调试兜底仅支持 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..3922349a 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 local 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 "实验性本机调试密钥获取失败,已停止本次操作。",
+ "data": {
+ "platform": "macos",
+ "method": "macos_inplace_native",
+ "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_native",
+ "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": "实验性本机密钥捕获只能在运行 WCDA 的 Mac 本机界面操作。",
+ "data": {
+ "platform": current_platform(),
+ "method": "macos_inplace_native",
+ "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,220 @@ async def watch_disconnect() -> None:
}
+@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": "实验性本机密钥捕获仅支持 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_native",
+ "active_operation": _current_macos_capture_operation(),
+ },
+ }
+
+
+@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": "实验性本机密钥捕获仅支持 macOS。",
+ "data": {"platform": current_platform(), "error_code": "UNSUPPORTED_PLATFORM"},
+ }
+ if not _macos_lldb_fallback_available():
+ return {
+ "status": -1,
+ "errmsg": "当前 Mac 不满足实验性本机调试兜底条件,请确认使用 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_native",
+ "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 本机捕获点")
+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": "实验性本机密钥捕获仅支持 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_native",
+ "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 本机密钥捕获")
+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": "实验性本机密钥捕获仅支持 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_native",
+ "stage": "completed",
+ "validated": True,
+ "key_saved": True,
+ "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 本机密钥捕获")
+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": "实验性本机密钥捕获仅支持 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_native",
+ "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..e887f998 100644
--- a/tests/test_decrypt_image_keys_frontend.py
+++ b/tests/test_decrypt_image_keys_frontend.py
@@ -112,21 +112,66 @@ 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_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()
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 "受控组件失败,是否改用本机调试兜底" 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 "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
new file mode 100644
index 00000000..7e0c50b5
--- /dev/null
+++ b/tests/test_macos_clone_capture.py
@@ -0,0 +1,488 @@
+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
+
+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,
+ capture_salt_matched_passphrase,
+ 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 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("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"))
+ 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)
+ 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)
+ 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(
+ {
+ "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])
+
+ @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..4f4ae224
--- /dev/null
+++ b/tests/test_macos_inplace_capture.py
@@ -0,0 +1,223 @@
+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,
+ native_capture_monitor_ready,
+ 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):
+ result = 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_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": 222, "stub_file_address": 4096}),
+ 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._candidate_bundle_pids", return_value=[654, 321]),
+ patch(
+ "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(
+ "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},
+ ),
+ ):
+ result = capture_prepared_in_place(
+ "/Applications/WeChat.app",
+ backup_root=debug_root / "backups",
+ probe_db_path=probe,
+ debug_root=debug_root,
+ )
+
+ 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:
+ 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, "stub_file_address": 4096}),
+ 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._candidate_bundle_pids", return_value=[321]),
+ patch(
+ "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,
+ 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_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/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 a3087d13..b4090e53 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,171 @@ 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_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),
+ 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.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"])
+ 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:
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())