diff --git a/src/main/index.ts b/src/main/index.ts index 0e4733f..d927cfe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -924,7 +924,7 @@ app.whenReady().then(async () => { } const result = process.platform === 'win32' - ? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus) + ? await keyServiceWin.autoGetImageKey(accountRoot, onStatus, wxid) : await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid) if (!result.success || !result.aesKey) return result diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts index 97a9cbe..2ee6bc0 100644 --- a/src/main/key-service-win.ts +++ b/src/main/key-service-win.ts @@ -1,10 +1,15 @@ import { join, dirname, delimiter } from 'path' -import { existsSync, copyFileSync, mkdirSync } from 'fs' +import { existsSync, copyFileSync, mkdirSync, readdirSync } from 'fs' import { execFile } from 'child_process' import { promisify } from 'util' import os from 'os' import crypto from 'crypto' import { getResourceRoots as getSharedResourceRoots } from './resource-paths' +import { + deriveV4ImageKeys, + extractKvcommCode, + normalizeV4AccountId +} from '../shared/wechat-image-key-derivation' const execFileAsync = promisify(execFile) @@ -683,7 +688,8 @@ export class KeyService { if (loginRequired) { return { success: false, - error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ', + error: + '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ', logs } } @@ -696,8 +702,7 @@ export class KeyService { onProgress?: (message: string) => void, wxidParam?: string ): Promise { - void wxidParam - return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress) + return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam) } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- @@ -706,7 +711,8 @@ export class KeyService { async autoGetImageKeyByMemoryScan( userDir: string, - onProgress?: (message: string) => void + onProgress?: (message: string) => void, + wxidParam?: string ): Promise { if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } @@ -781,6 +787,18 @@ export class KeyService { onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + const derived = this._deriveImageKeyByLocalMetadata( + userDir, + wxidParam, + ciphertext, + xorKey, + onProgress + ) + if (derived) { + onProgress?.('通过本机账号元数据推导并验证图片密钥成功') + return { success: true, xorKey: derived.xorKey, aesKey: derived.aesKey, verified: true } + } + // 2. 找微信 PID const pid = await this.findWeChatPid() if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } @@ -811,6 +829,73 @@ export class KeyService { } } + private _deriveImageKeyByLocalMetadata( + userDir: string, + wxidParam: string | undefined, + ciphertext: Buffer, + expectedXorKey: number, + onProgress?: (message: string) => void + ): { xorKey: number; aesKey: string } | null { + const codes = new Set() + for (const directory of this._getKvcommCandidates(userDir)) { + try { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile()) continue + const code = extractKvcommCode(entry.name) + if (code !== null) codes.add(code) + } + } catch { + // Candidate paths differ across WeChat releases and installations. + } + } + + const accountIds = new Set() + for (const candidate of [wxidParam, userDir]) { + const normalized = normalizeV4AccountId(candidate || '') + if (normalized) accountIds.add(normalized) + } + onProgress?.(`正在校验本机账号元数据候选(code=${codes.size}, account=${accountIds.size})...`) + + for (const accountId of accountIds) { + for (const code of codes) { + const derived = deriveV4ImageKeys(code, accountId) + if (!derived || derived.xorKey !== expectedXorKey) continue + if (this._verifyAesKey(Buffer.from(derived.aesKey, 'ascii'), ciphertext)) return derived + } + } + return null + } + + private _getKvcommCandidates(userDir: string): string[] { + const candidates: string[] = [] + const seen = new Set() + const add = (candidate: string | undefined) => { + if (!candidate) return + const normalized = candidate.replace(/[\\/]+$/, '') + const identity = normalized.toLowerCase() + if (!normalized || seen.has(identity)) return + seen.add(identity) + candidates.push(normalized) + } + + const roaming = process.env.APPDATA + const local = process.env.LOCALAPPDATA + add(roaming && join(roaming, 'Tencent', 'xwechat', 'net', 'kvcomm')) + add(roaming && join(roaming, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm')) + add(local && join(local, 'Tencent', 'xwechat', 'net', 'kvcomm')) + add(local && join(local, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm')) + add(local && join(local, 'Tencent', 'WeChat', 'xwechat', 'net', 'kvcomm')) + + let cursor = userDir + for (let depth = 0; cursor && depth < 6; depth++) { + add(join(cursor, 'net', 'kvcomm')) + const parent = dirname(cursor) + if (parent === cursor) break + cursor = parent + } + return candidates + } + private async _findTemplateData( userDir: string, limit: number = 32 diff --git a/src/shared/wechat-image-key-derivation.ts b/src/shared/wechat-image-key-derivation.ts new file mode 100644 index 0000000..690fb71 --- /dev/null +++ b/src/shared/wechat-image-key-derivation.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto' + +export type DerivedV4ImageKeys = { xorKey: number; aesKey: string } + +export function normalizeV4AccountId(value: string): string { + const leaf = String(value || '') + .trim() + .split(/[\\/]/) + .filter(Boolean) + .pop() + if (!leaf) return '' + const wxid = leaf.match(/^(wxid_[^_]+)/i) + if (wxid) return wxid[1] + return leaf.replace(/_[a-z0-9]{4}$/i, '') +} + +export function extractKvcommCode(fileName: string): number | null { + const match = String(fileName || '').match(/^key_(\d+)_.+\.statistic$/i) + if (!match) return null + const code = Number.parseInt(match[1], 10) + if (!Number.isSafeInteger(code) || code <= 0 || code > 0xffffffff) return null + return code +} + +export function deriveV4ImageKeys(code: number, accountId: string): DerivedV4ImageKeys | null { + const normalizedAccountId = normalizeV4AccountId(accountId) + if (!Number.isSafeInteger(code) || code <= 0 || code > 0xffffffff || !normalizedAccountId) { + return null + } + const digest = crypto + .createHash('md5') + .update(`${code}${normalizedAccountId}`, 'utf8') + .digest('hex') + return { xorKey: code & 0xff, aesKey: digest.slice(0, 16) } +} diff --git a/tests/unit/wechat-image-key-derivation.test.ts b/tests/unit/wechat-image-key-derivation.test.ts new file mode 100644 index 0000000..a47596e --- /dev/null +++ b/tests/unit/wechat-image-key-derivation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + deriveV4ImageKeys, + extractKvcommCode, + normalizeV4AccountId +} from '../../src/shared/wechat-image-key-derivation' + +describe('WeChat 4 image key derivation', () => { + it('normalizes account directories without leaking the per-install suffix', () => { + expect(normalizeV4AccountId('C:\\data\\wxid_fixture_ab12')).toBe('wxid_fixture') + expect(normalizeV4AccountId('/data/custom-account_ab12')).toBe('custom-account') + }) + + it('extracts only valid uint32 kvcomm codes', () => { + expect(extractKvcommCode('key_123456789_network.statistic')).toBe(123456789) + expect(extractKvcommCode('key_0_network.statistic')).toBeNull() + expect(extractKvcommCode('other_123_network.statistic')).toBeNull() + }) + + it('derives the documented XOR byte and 16-character AES key', () => { + expect(deriveV4ImageKeys(123456789, 'wxid_fixture_ab12')).toEqual({ + xorKey: 0x15, + aesKey: '3bc2c5cbb10dfeda' + }) + }) +})