Merge pull request #474 from H3CoF6/feat/linux

feat: linux设置密钥获取
This commit is contained in:
xuncha
2026-03-17 11:08:17 +08:00
committed by GitHub
8 changed files with 329 additions and 14 deletions

1
.gitignore vendored
View File

@@ -68,3 +68,4 @@ CLAUDE.md
resources/wx_send
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml

View File

@@ -17,6 +17,7 @@ import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService'
import { KeyServiceMac } from './services/keyServiceMac'
import { KeyServiceLinux } from './services/keyServiceLinux';
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService'
@@ -89,9 +90,15 @@ let onboardingWindow: BrowserWindow | null = null
let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>()
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
const keyService = process.platform === 'darwin'
? new KeyServiceMac() as any
: new KeyService()
let keyService: KeyService | KeyServiceMac | KeyServiceLinux;
if (process.platform === 'darwin') {
keyService = new KeyServiceMac();
} else if (process.platform === 'linux') {
keyService = new KeyServiceLinux();
} else {
keyService = new KeyService();
}
let mainWindowReady = false
let shouldShowMain = true

View File

@@ -16,7 +16,7 @@ interface ConfigSchema {
imageXorKey: number
imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
exportPath?: string;
// 缓存相关
cachePath: string
lastOpenedDb: string

View File

@@ -0,0 +1,282 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec } from 'child_process'
import { promisify } from 'util'
import sudo from 'sudo-prompt'
const execFileAsync = promisify(execFile)
const execAsync = promisify(exec)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyServiceLinux {
private getHelperPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else {
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
}
for (const p of candidates) {
if (existsSync(p)) return p
}
throw new Error('找不到 xkey_helper_linux请检查路径')
}
public async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
try {
onStatus?.('正在尝试结束当前微信进程...', 0)
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
// 稍微等待进程完全退出
await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &',
'nohup wechat-bin >/dev/null 2>&1 &',
'nohup xwechat >/dev/null 2>&1 &'
]
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
onStatus?.('等待微信进程出现...', 0)
let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000))
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length > 0) {
pid = parseInt(pids[0], 10)
break
}
}
if (!pid) {
const err = '未能自动启动微信,请手动启动并登录。'
onStatus?.(err, 2)
return { success: false, error: err }
}
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
await new Promise(r => setTimeout(r, 2000))
return await this.getDbKey(pid, onStatus)
} catch (err: any) {
const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2)
return { success: false, error: errMsg }
}
}
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
try {
const helperPath = this.getHelperPath()
onStatus?.('正在扫描数据库基址...', 0)
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
const scanRes = JSON.parse(scanOut.trim())
if (!scanRes.success) {
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
onStatus?.(err, 2)
return { success: false, error: err }
}
const targetAddr = scanRes.target_addr
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
return await new Promise((resolve) => {
const options = { name: 'WeFlow' }
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
sudo.exec(command, options, (error, stdout) => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
if (error) {
onStatus?.('授权失败或被取消', 2)
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
return
}
try {
const hookRes = JSON.parse((stdout as string).trim())
if (hookRes.success) {
onStatus?.('密钥获取成功', 1)
resolve({ success: true, key: hookRes.key })
} else {
onStatus?.(hookRes.result, 2)
resolve({ success: false, error: hookRes.result })
}
} catch (e) {
onStatus?.('解析 Hook 结果失败', 2)
resolve({ success: false, error: '解析 Hook 结果失败' })
}
})
})
} catch (err: any) {
onStatus?.(err.message, 2)
return { success: false, error: err.message }
}
}
public async autoGetImageKey(
accountPath?: string,
onProgress?: (msg: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onProgress?.('正在初始化缓存扫描...');
const helperPath = this.getHelperPath()
const { stdout } = await execFileAsync(helperPath, ['image_local'])
const res = JSON.parse(stdout.trim())
if (!res.success) return { success: false, error: res.result }
const accounts = res.data.accounts || []
let account = accounts.find((a: any) => a.wxid === wxid)
if (!account && accounts.length > 0) account = accounts[0]
if (account && account.keys && account.keys.length > 0) {
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
const keyObj = account.keys[0]
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
}
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
public async autoGetImageKeyByMemoryScan(
accountPath: string,
onProgress?: (msg: string) => void
): Promise<ImageKeyResult> {
try {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(accountPath, 32)
let { ciphertext, xorKey } = result
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(accountPath, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
const pid = parseInt(pids[0], 10)
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
// 3. 将 Buffer 转换为 hex 传递给 helper
const ciphertextHex = ciphertext.toString('hex')
const helperPath = this.getHelperPath()
try {
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
console.log(`[Debug] Helper stdout: ${memOut}`);
if (stderr) {
console.warn(`[Debug] Helper stderr: ${stderr}`);
}
if (!memOut || memOut.trim() === '') {
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
}
const res = JSON.parse(memOut.trim())
if (res.success) {
onProgress?.('内存扫描成功');
return { success: true, xorKey, aesKey: res.key }
}
return { success: false, error: res.result || '未知错误' }
} catch (err: any) {
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
return {
success: false,
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
}
}
} catch (err: any) {
return { success: false, error: `内存扫描失败: ${err.message}` }
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
}
const files: string[] = []
collect(userDir, files, limit)
// 按修改时间降序
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
})
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
for (const f of files.slice(0, 32)) {
try {
const data = readFileSync(f)
if (data.length < 8) continue
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
}
// 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
}
} catch { /* 忽略 */ }
}
// 计算 XOR 密钥
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xFF
if (k === (y ^ 0xD9)) xorKey = k
}
}
return { ciphertext, xorKey }
}
}

View File

@@ -40,6 +40,7 @@
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},

BIN
resources/xkey_helper_linux Executable file

Binary file not shown.

View File

@@ -30,6 +30,16 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'about', label: '关于', icon: Info }
]
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
: isLinux
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
interface WxidOption {
wxid: string
@@ -1371,7 +1381,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint">xwechat_files </span>
<input
type="text"
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
placeholder={dbPathPlaceholder}
value={dbPath}
onChange={(e) => {
const value = e.target.value

View File

@@ -11,9 +11,19 @@ import {
import ConfirmDialog from '../components/ConfirmDialog'
import './WelcomePage.scss'
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
: isLinux
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
const steps = [
{ id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' },
{ id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' },
{ id: 'db', title: '数据库目录', desc: `定位 ${dbDirName}` },
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
@@ -637,7 +647,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<input
type="text"
className="field-input"
placeholder="例如C:\\Users\\xxx\\Documents\\xwechat_files"
placeholder={dbPathPlaceholder}
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
/>
@@ -888,13 +898,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</div>
<ConfirmDialog
open={showDbKeyConfirm}
title="开始获取数据库密钥"
message={`当开始获取后 WeFlow 将会执行准备操作
当 WeFlow 内的提示条变为绿色显示允许登录或看到来自WeFlow的登录通知时登录你的微信或退出当前登录并重新登录。`}
onConfirm={handleDbKeyConfirm}
onCancel={() => setShowDbKeyConfirm(false)}
open={showDbKeyConfirm}
title="开始获取数据库密钥"
message={`当开始获取后 WeFlow 将会执行准备操作
${isLinux ? `
【⚠️ Linux 用户特别注意】
如果您在微信里勾选了“自动登录”,请务必先关闭自动登录,然后再点击下方确认!
(因为授权弹窗输入密码需要时间,若自动登录太快会导致获取失败)
` : ''}
当 WeFlow 内的提示条变为绿色显示允许登录或看到来自 WeFlow 的登录通知时,请在手机上确认登录微信。`}
onConfirm={handleDbKeyConfirm}
onCancel={() => setShowDbKeyConfirm(false)}
/>
</div>
</div>