Merge pull request #31 from njueeRay/fix/windows-v4-image-key-derivation

fix: derive Windows image keys from local metadata
This commit is contained in:
qingmao
2026-09-03 17:53:15 +08:00
committed by GitHub
4 changed files with 152 additions and 6 deletions
+1 -1
View File
@@ -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
+90 -5
View File
@@ -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<ImageKeyResult> {
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<ImageKeyResult> {
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<number>()
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<string>()
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<string>()
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
+35
View File
@@ -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) }
}
@@ -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'
})
})
})