Files
WechatExplorer/src/main/key-service-mac.ts
T

678 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from 'electron'
import { execFile, spawn } from 'child_process'
import fs from 'fs-extra'
import path from 'path'
import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store'
import crypto from 'crypto'
import { findResource, getResourceCandidates } from './resource-paths'
const execFileAsync = promisify(execFile)
export interface DatabaseKeyResult {
success: boolean
key?: string
error?: string
code?: string
}
export interface ImageKeyResult {
success: boolean
xorKey?: number
aesKey?: string
verified?: boolean
error?: string
}
export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] {
return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account']
}
export function mapXkeyHelperFailure(
rawError: string,
fallbackCode = 'HELPER_RESULT_INVALID'
): DatabaseKeyResult {
const normalizedError = rawError.trim().toLowerCase()
const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (
code === 'CAPTURE_TIMEOUT' ||
normalizedError.includes('timeout waiting for breakpoint hit') ||
normalizedError.includes('timeout waiting for sink hit') ||
normalizedError.includes('no_breakpoint_hit')
) {
return {
success: false,
code: 'CAPTURE_TIMEOUT',
error:
'已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。'
}
}
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: '内存扫描失败:当前微信版本或运行状态暂未适配。'
}
}
if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') {
return {
success: false,
code: code || 'PERMISSION_DENIED',
error: '管理员授权不足,无法读取微信进程内存。'
}
}
return {
success: false,
code: code || fallbackCode,
error: code
? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。`
: '密钥工具未返回有效密钥,请确认微信仍在运行后重试。'
}
}
export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey }
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
return mapXkeyHelperFailure(rawError)
}
export class KeyServiceMac {
private getMacKeyRuntimeDir(): string {
return path.join(app.getPath('userData'), 'key-runtime')
}
private async runMacKeyTool(
args: string[],
timeoutMs: number,
onStatus?: (message: string) => void
): Promise<Record<string, unknown>> {
const helperPath = findResource('macos-key-tool/intel_mac_key_helper')
if (!helperPath) {
return {
type: 'result',
success: false,
code: 'KEY_TOOL_UNAVAILABLE',
error: '缺少 Intel Mac 工具'
}
}
return await new Promise<Record<string, unknown>>((resolve) => {
const child = spawn(helperPath, args, {
stdio: ['ignore', 'pipe', 'pipe']
})
let settled = false
let pending = ''
let lastError = ''
let finalPayload: Record<string, unknown> | null = null
let timer: ReturnType<typeof setTimeout> | undefined
const finish = (payload: Record<string, unknown>): void => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
resolve(payload)
}
const consume = (chunk: Buffer): void => {
pending += chunk.toString()
const lines = pending.split(/\r?\n/)
pending = lines.pop() || ''
for (const line of lines) {
try {
const payload = JSON.parse(line) as Record<string, unknown>
if (payload.type === 'progress' && typeof payload.message === 'string') {
onStatus?.(payload.message)
}
if (payload.type === 'result' || payload.type === 'status') finalPayload = payload
} catch {
// The helper contract is JSONL; ignore interpreter diagnostics.
}
}
}
child.stdout.on('data', consume)
child.stderr.on('data', (chunk: Buffer) => {
lastError = `${lastError}\n${chunk.toString()}`.trim().slice(-1000)
})
child.on('error', (error) =>
finish({
type: 'result',
success: false,
code: 'KEY_TOOL_FAILED',
error: `无法启动 Intel Mac 密钥工具:${error.message}`
})
)
child.on('close', () => {
if (pending) consume(Buffer.from('\n'))
finish(
finalPayload || {
type: 'result',
success: false,
code: 'KEY_TOOL_FAILED',
error: lastError || 'Intel Mac 密钥工具未返回结果'
}
)
})
timer = setTimeout(() => {
try {
child.kill('SIGTERM')
} catch {
// The child may already have exited.
}
finish({
type: 'result',
success: false,
code: 'KEY_TOOL_TIMEOUT',
error: 'Intel Mac 密钥获取超时,请让微信回到未登录界面后重试'
})
}, timeoutMs)
})
}
async getIntelEnvironmentStatus(): Promise<{
sipDisabled: boolean
pythonAvailable: boolean
fridaAvailable: boolean
wechatAdhocSigned: boolean
}> {
const payload = await this.runMacKeyTool(
['status', '--runtime-dir', this.getMacKeyRuntimeDir()],
15_000
)
return {
sipDisabled: payload.sipDisabled === true,
pythonAvailable: payload.pythonAvailable === true,
fridaAvailable: payload.fridaAvailable === true,
wechatAdhocSigned: payload.wechatAdhocSigned === true
}
}
async installIntelKeyRuntime(
onStatus?: (message: string) => void
): Promise<{ success: boolean; error?: string; code?: string }> {
if (process.platform !== 'darwin' || process.arch !== 'x64') {
return { success: false, code: 'UNSUPPORTED_PLATFORM', error: '仅 Intel Mac 需要安装此运行环境' }
}
onStatus?.('正在准备连接环境,请保持网络连接…')
const payload = await this.runMacKeyTool(
['install', this.getMacKeyRuntimeDir()],
5 * 60_000,
onStatus
)
return {
success: payload.success === true,
code: typeof payload.code === 'string' ? payload.code : undefined,
error: typeof payload.error === 'string' ? payload.error : undefined
}
}
private async captureIntelDbKey(
accountRoot: string | undefined,
timeoutMs: number,
onStatus?: (message: string) => void
): Promise<DatabaseKeyResult> {
const selectedRoot = String(accountRoot || '').trim()
if (!selectedRoot) return { success: false, code: 'ACCOUNT_REQUIRED', error: '请先选择微信账号' }
const waitMs = Math.max(30_000, timeoutMs)
const payload = await this.runMacKeyTool(
[
'capture',
'--account-root',
selectedRoot,
'--runtime-dir',
this.getMacKeyRuntimeDir(),
'--diagnostic-log',
path.join(app.getPath('logs'), 'mac-key-diagnostic.log'),
'--timeout-ms',
String(waitMs)
],
waitMs + 15_000,
onStatus
)
const key = typeof payload.key === 'string' ? payload.key.trim().toLowerCase() : ''
if (payload.success === true && isValidDatabaseKey(key)) return { success: true, key }
return {
success: false,
code: typeof payload.code === 'string' ? payload.code : 'KEY_TOOL_FAILED',
error: typeof payload.error === 'string' ? payload.error : '未捕获到微信数据库密钥'
}
}
private getHelperPath(): string {
const helperPath = findResource('xkey_helper')
if (!helperPath) {
throw new Error(
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
)
}
return helperPath
}
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
} catch {
return false
}
}
private async getWeChatPid(): Promise<number> {
const commands: [string, string[]][] = [
['/usr/bin/pgrep', ['-x', 'WeChat']],
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
]
for (const [command, args] of commands) {
try {
const { stdout } = await execFileAsync(command, args)
const pids = stdout
.split(/\r?\n/)
.map((value) => Number.parseInt(value.trim(), 10))
.filter((value) => Number.isFinite(value) && value > 0)
if (pids.length) return Math.max(...pids)
} catch {
// Try the next process lookup strategy.
}
}
throw new Error('未找到微信主进程,请先启动微信并停留在登录界面')
}
async autoGetDbKey(
onStatus?: (message: string) => void,
timeoutMs = 60_000,
accountRoot?: string
): Promise<DatabaseKeyResult> {
if (onStatus) {
const emitStatus = onStatus
onStatus = (message: string): void =>
emitStatus(message.replace(/Frida/gi, '连接组件'))
}
if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
}
}
try {
if (process.arch === 'x64') {
onStatus?.('Intel Mac 将通过 Frida 获取微信主密钥,请让微信停留在未登录界面')
const result = await this.captureIntelDbKey(accountRoot, timeoutMs, onStatus)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
}
onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath()
const waitMs = Math.max(120_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + 10
const helperArguments = buildXkeyHelperArguments(pid, waitMs)
onStatus?.('正在请求管理员授权...')
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${helperArguments.join(' ')}"`,
`set timeoutSec to ${timeoutSeconds}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "OK::" & outText',
'on error errMsg number errNum',
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码')
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
{ timeout: timeoutSeconds * 1000 + 5_000 }
)
const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) {
return { success: false, error: '已取消管理员授权' }
}
if (output.startsWith('ERR::')) {
const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::')
const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`)
onStatus?.('密钥获取失败')
return result
}
const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
} catch (error) {
const processError = error as NodeJS.ErrnoException & {
killed?: boolean
signal?: NodeJS.Signals | null
}
if (processError.message?.includes('未找到微信主进程')) {
return {
success: false,
code: 'WECHAT_NOT_RUNNING',
error: '未找到微信主进程,请先启动微信并停留在登录界面。'
}
}
if (
processError.killed ||
processError.code === 'ETIMEDOUT' ||
processError.signal === 'SIGTERM'
) {
return {
success: false,
code: 'AUTH_TIMEOUT',
error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。'
}
}
return {
success: false,
code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED',
error: '密钥工具执行失败,请确认微信仍在运行后重试。'
}
}
}
async autoGetImageKey(
accountPath?: string,
onStatus?: (message: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onStatus?.('正在从缓存目录扫描图片密钥...')
const codes = this.collectKvcommCodes(accountPath)
if (codes.length === 0) {
return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' }
}
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
for (const candidateAccountPath of accountPathCandidates) {
if (!fs.existsSync(candidateAccountPath)) continue
const template = this.findTemplateData(candidateAccountPath, 32)
if (!template.ciphertext) continue
const orderedWxids: string[] = []
this.pushAccountIdCandidates(orderedWxids, path.basename(candidateAccountPath))
for (const candidate of wxidCandidates)
this.pushAccountIdCandidates(orderedWxids, candidate)
onStatus?.(`正在校验候选 wxid(${orderedWxids.length} 个)...`)
for (const candidateWxid of orderedWxids) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
onStatus?.(`图片密钥获取成功 (wxid: ${candidateWxid})`)
return { success: true, xorKey, aesKey, verified: true }
}
}
}
const fallbackWxid = wxidCandidates[0]
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onStatus?.(`图片密钥已计算 (wxid: ${fallbackWxid})`)
return { success: true, xorKey, aesKey, verified: false }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
private collectKvcommCodes(accountPath?: string): number[] {
const codeSet = new Set<number>()
const pattern = /^key_(\d+)_.+\.statistic$/i
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
if (!fs.existsSync(kvcommDir)) continue
try {
for (const file of fs.readdirSync(kvcommDir)) {
const match = file.match(pattern)
if (!match) continue
const code = Number(match[1])
if (Number.isFinite(code) && code > 0 && code <= 0xffffffff) codeSet.add(code)
}
} catch {
// Try the next candidate.
}
}
return Array.from(codeSet)
}
private getKvcommCandidates(accountPath?: string): string[] {
const home = app.getPath('home')
const candidates = new Set<string>([
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm'
),
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm'
),
path.join(
home,
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/net/kvcomm'
),
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat/net/kvcomm')
])
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
const marker = '/xwechat_files'
const markerIndex = normalized.indexOf(marker)
if (markerIndex >= 0) {
candidates.add(`${normalized.slice(0, markerIndex)}/app_data/net/kvcomm`)
}
const newPathMatch = normalized.match(
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/
)
if (newPathMatch) {
candidates.add(`${newPathMatch[1]}/net/kvcomm`)
candidates.add(`${newPathMatch[1]}/xwechat/net/kvcomm`)
}
return Array.from(candidates)
}
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
const candidates: string[] = []
this.pushAccountIdCandidates(candidates, wxidParam)
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
if (normalized) {
this.pushAccountIdCandidates(candidates, path.basename(normalized))
const root = this.resolveXwechatRootFromPath(normalized)
if (root && fs.existsSync(root)) {
try {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = path.join(root, entry.name)
if (this.isAccountDirPath(entryPath))
this.pushAccountIdCandidates(candidates, entry.name)
}
} catch {
// Ignore unreadable directories.
}
}
}
if (candidates.length === 0) candidates.push('unknown')
return candidates
}
private collectAccountPathCandidates(accountPath?: string): string[] {
const candidates: string[] = []
const push = (value?: string): void => {
const normalized = String(value || '').trim()
if (normalized && !candidates.includes(normalized)) candidates.push(normalized)
}
push(accountPath)
const root = this.resolveXwechatRootFromPath(accountPath)
if (root && fs.existsSync(root)) {
try {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const entryPath = path.join(root, entry.name)
if (this.isAccountDirPath(entryPath) && this.isReasonableAccountId(entry.name))
push(entryPath)
}
} catch {
// Ignore unreadable directories.
}
}
return candidates
}
private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
if (!normalized) return null
const marker = '/xwechat_files'
const markerIndex = normalized.indexOf(marker)
if (markerIndex >= 0) return normalized.slice(0, markerIndex + marker.length)
const newPathMatch = normalized.match(
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/
)
return newPathMatch ? newPathMatch[1] : null
}
private isAccountDirPath(entryPath: string): boolean {
return (
fs.existsSync(path.join(entryPath, 'db_storage')) ||
fs.existsSync(path.join(entryPath, 'msg')) ||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image')) ||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image2'))
)
}
private pushAccountIdCandidates(candidates: string[], value?: string): void {
const raw = String(value || '').trim()
if (!this.isReasonableAccountId(raw)) return
for (const candidate of [raw, this.normalizeAccountId(raw)]) {
if (candidate && !candidates.includes(candidate) && this.isReasonableAccountId(candidate)) {
candidates.push(candidate)
}
}
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
private isReasonableAccountId(value: string): boolean {
const lowered = String(value || '')
.trim()
.toLowerCase()
if (!lowered || lowered.includes('/') || lowered.includes('\\')) return false
return !['xwechat_files', 'all_users', 'backup', 'wmpf', 'app_data'].includes(lowered)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const xorKey = code & 0xff
const aesKey = crypto
.createHash('md5')
.update(`${code}${this.normalizeAccountId(wxid)}`)
.digest('hex')
.substring(0, 16)
return { xorKey, aesKey }
}
private findTemplateData(userDir: string, limit = 32): { ciphertext: Buffer | null } {
const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
const files: string[] = []
const collect = (dir: string): void => {
if (files.length >= limit) return
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (files.length >= limit) break
const full = path.join(dir, entry.name)
if (entry.isDirectory()) collect(full)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) files.push(full)
}
} catch {
// Ignore unreadable directories.
}
}
collect(userDir)
files.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs
} catch {
return 0
}
})
for (const file of files) {
try {
const data = fs.readFileSync(file)
if (data.length >= 0x1f && data.subarray(0, 6).equals(magic)) {
return { ciphertext: data.subarray(0x0f, 0x1f) }
}
} catch {
// Try the next file.
}
}
return { ciphertext: null }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv(
'aes-128-ecb',
Buffer.from(aesKey, 'ascii').subarray(0, 16),
null
)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
}