173 lines
9.5 KiB
Python

"""Opt-in debug copy. Never re-sign the installed app or change system policy."""
import hashlib
import json
import plistlib
import secrets
import shutil
import subprocess
import time
from pathlib import Path
from .mac_keys import wechat_pid
def run(args, timeout=90):
result = subprocess.run(args, capture_output=True, timeout=timeout)
if result.returncode:
raise ValueError('系统操作未成功:'+Path(args[0]).name+'。请检查应用完整性、文件访问权限或系统提示;原版应用没有被重签。')
return result
def entitlements(app):
result = run(['/usr/bin/codesign', '-d', '--entitlements', ':-', str(app)])
for data in (result.stdout, result.stderr):
start = data.find(b'<?xml')
end = data.find(b'</plist>')
if start >= 0 and end >= start:
value = plistlib.loads(data[start:end+8])
if not isinstance(value, dict):
raise ValueError('应用权限文件格式错误')
return value
if data.startswith(b'bplist'):
return plistlib.loads(data)
return {}
def fingerprint(app):
digest = hashlib.sha256()
for name in ('Contents/Info.plist', 'Contents/MacOS/WeChat', 'Contents/_CodeSignature/CodeResources'):
path = app/name
if path.is_file():
with path.open('rb') as file:
while chunk := file.read(1024**2):
digest.update(chunk)
return digest.hexdigest()
def inventory(root):
result = {}
for path in root.rglob('*'):
if path.is_symlink():
raise ValueError('账号数据包含符号链接,无法创建独立的完整备份,已停止')
if path.is_file():
stat = path.stat()
result[str(path.relative_to(root))] = [stat.st_size, stat.st_mtime_ns]
return result
def safe_app_links(app):
for link in app.rglob('*'):
if link.is_symlink() and (link.readlink().is_absolute() or not link.resolve().is_relative_to(app)):
raise ValueError('应用包含绝对路径或越界的符号链接,无法确保仅修改副本,已停止')
class DebugRecovery:
def __init__(self, data):
self.data = Path(data).resolve()
self.plans = {}
self.prepared = None
def plan(self, account, app=None):
candidates = [Path('/Applications/WeChat.app'), Path.home()/'Applications/WeChat.app']
app = app or next((p for p in candidates if p.is_dir()), None)
if not app:
raise ValueError('未找到已安装的原版 WeChat.app')
app = app.resolve()
if self.data.is_relative_to(app) or app.is_relative_to(self.data):
raise ValueError('工具数据目录不能与原版微信应用目录重叠')
safe_app_links(app)
info = plistlib.loads((app/'Contents/Info.plist').read_bytes())
if info.get('CFBundleIdentifier') != 'com.tencent.xinWeChat' or info.get('CFBundleExecutable') != 'WeChat':
raise ValueError('应用标识不符合预期,已停止')
run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(app)])
token = secrets.token_hex(16)
root = self.data/'mac_debug'/token
# Copy the entire account plus login configuration before any clone launch.
sources = [Path(account).parent]
all_users = Path(account).parent.parent/'all_users'
if all_users.is_dir():
sources.append(all_users)
size = sum(sum(v[0] for v in inventory(source).values()) for source in sources)
app_size = sum(p.stat().st_size for p in app.rglob('*') if p.is_file() and not p.is_symlink())
plan = {'id': token, 'created': time.monotonic(), 'app': app, 'account': Path(account),
'sources': sources, 'root': root, 'fingerprint': fingerprint(app),
'estimated_bytes': size+app_size+512*1024**2, 'version': str(info.get('CFBundleShortVersionString', '未知'))}
self.plans = {token: plan}
return {'plan_id': token, 'source': str(app), 'copy': str(root/'WeChat.app'),
'backup': str(root/'backup'), 'version': plan['version'], 'estimated_bytes': plan['estimated_bytes'],
'effects': ['先完整备份所选账号目录与本机登录配置,可能包含图片、附件,所需空间比只备份数据库更多。',
'仅对工具目录里的临时应用副本重新签名并允许调试;不改动已安装的原版微信,不关闭 SIP。',
'副本保留原应用标识,登录时可能使用或更新同一账号的本地数据,可能要求重新登录或系统授权。',
'有调试权限的副本更容易被本机调试工具读取。仅用于本人账号取钥,用完退出副本并恢复原版。',
'副本能否启动、登录和取钥仍需本机验证;失败不会自动重试、删除备份或修改原版。']}
def consume(self, token, consent, account):
if consent is not True:
raise ValueError('请先阅读操作影响并明确勾选同意,才能创建调试副本')
plan = self.plans.pop(token, None)
if not plan or time.monotonic()-plan['created'] > 1800:
raise ValueError('副本操作预览已过期或已使用,请重新预览')
if Path(account) != plan['account']:
raise ValueError('账号发生变化,请重新预览')
return plan
def prepare(self, plan, stage):
if wechat_pid():
raise ValueError('请先正常退出所有微信,再创建临时副本;知微保持运行')
if fingerprint(plan['app']) != plan['fingerprint']:
raise ValueError('原版微信在预览后发生变化,请重新预览')
safe_app_links(plan['app'])
run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(plan['app'])])
self.data.mkdir(parents=True, exist_ok=True, mode=0o700)
if shutil.disk_usage(self.data).free < plan['estimated_bytes']:
raise ValueError('磁盘空间不足以完整备份账号并创建应用副本')
root = plan['root']
if not root.resolve().is_relative_to(self.data) or root.exists():
raise ValueError('副本目录无效或已存在,已停止')
root.mkdir(parents=True, mode=0o700)
stage('正在完整备份账号数据和登录配置;请保持微信退出')
manifest = []
for index, source in enumerate(plan['sources']):
before = inventory(source)
destination = root/'backup'/f'{index}-{source.name}'
shutil.copytree(source, destination)
copied = inventory(destination)
if {name:value[0] for name,value in copied.items()} != {name:value[0] for name,value in before.items()}:
raise ValueError('备份文件清单或大小不完整,已停止,未创建调试副本')
if inventory(source) != before or wechat_pid():
raise ValueError('备份期间账号数据发生变化,已停止;请退出微信后重新预览')
manifest.append({'source': str(source), 'backup': str(destination), 'files': len(before)})
(root/'backup-manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
stage('正在创建临时应用副本;原版安装文件不修改')
copy = root/'WeChat.app'
shutil.copytree(plan['app'], copy, symlinks=True)
safe_app_links(copy)
if fingerprint(copy) != plan['fingerprint'] or fingerprint(plan['app']) != plan['fingerprint']:
raise ValueError('应用复制前后不一致,请重新预览')
grants = entitlements(plan['app'])
grants['com.apple.security.get-task-allow'] = True
permissions = root/'debug-entitlements.plist'
permissions.write_bytes(plistlib.dumps(grants))
stage('仅为临时副本添加调试权限并校验签名')
run(['/usr/bin/codesign', '--force', '--deep', '--options', 'runtime', '--timestamp=none',
'--sign', '-', '--entitlements', str(permissions), str(copy)], timeout=180)
run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(copy)])
if entitlements(copy).get('com.apple.security.get-task-allow') is not True:
raise ValueError('临时副本未取得预期调试权限,已停止')
if fingerprint(plan['app']) != plan['fingerprint']:
raise ValueError('原版应用在操作期间发生变化,请先检查原版状态')
self.prepared = {'app': copy, 'fingerprint': fingerprint(copy), 'root': root, 'original': plan['app']}
return {'message': '备份与临时副本已准备。下一步点击“启动临时副本”,在副本中登录并打开聊天,再读取密钥。',
'backup': str(root/'backup'), 'copy': str(copy), 'original_unchanged': True}
def launch(self):
if not self.prepared:
raise ValueError('请先预览并创建临时副本')
if wechat_pid():
raise ValueError('请先退出正在运行的微信,再启动临时副本')
copy = self.prepared['app']
if not copy.resolve().is_relative_to(self.data) or fingerprint(copy) != self.prepared['fingerprint']:
raise ValueError('临时副本已变化,拒绝启动,请重新准备')
run(['/usr/bin/open', '-n', str(copy)])
return {'message': '已向 macOS 请求启动临时副本。请按系统提示完成打开/登录,进入聊天后再点击读取密钥;未自动绕过系统拦截。'}