173 lines
9.5 KiB
Python
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 请求启动临时副本。请按系统提示完成打开/登录,进入聊天后再点击读取密钥;未自动绕过系统拦截。'}
|