import { basename, dirname, extname, join } from 'path' import { existsSync, readFileSync, statSync, readdirSync } from 'fs' import crypto from 'crypto' import os from 'os' import { Wcdb4Client } from './wcdb4-client' export class ImageDecryptService { private readonly defaultV1AesKey = 'cfcd208495d565ef' private xorKey: number = 0 private aesKey: string = '' private wcdb4Client: Wcdb4Client | null = null constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) { // 解析 XOR Key (支持 0x40 或 64 格式) const xorHex = xorKey.trim().toLowerCase() if (xorHex.startsWith('0x')) { this.xorKey = parseInt(xorHex, 16) } else { this.xorKey = parseInt(xorHex, 10) } // AES Key 直接使用 this.aesKey = aesKey.trim() this.wcdb4Client = wcdb4Client || null } /** * 获取账号目录 */ private getAccountDir(): string | null { const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot() if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) { return wcdbAccountRoot } const homeDir = os.homedir() const accountRoot = join( homeDir, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' ) if (!existsSync(accountRoot)) { console.log('[ImageDecrypt] account root not found:', accountRoot) return null } const accounts = readdirSync(accountRoot) .filter((name) => { const fullPath = join(accountRoot, name) try { return statSync(fullPath).isDirectory() } catch { return false } }) .map((name) => ({ name, mtime: statSync(join(accountRoot, name)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime) if (accounts.length === 0) { console.log('[ImageDecrypt] no accounts found') return null } // 返回最新的账号目录 return join(accountRoot, accounts[0].name) } /** * 根据 md5 查找图片文件 (WechatExplorer 风格) */ findImageFile(md5?: string, imageDatName?: string): string | null { const accountDir = this.getAccountDir() if (!accountDir) return null const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '') console.log('[ImageDecrypt] findImageFile:', { md5: normalizedMd5, imageDatName: normalizedDatName, accountDir }) for (const key of this.uniq([normalizedMd5, normalizedDatName])) { const hardlink = this.wcdb4Client?.resolveImageHardlink(key) const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : '' if (fullPath && existsSync(fullPath)) { console.log('[ImageDecrypt] hardlink hit:', fullPath) return this.getPreferredDatVariantPath(fullPath, true) } } // 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/ const attachDir = join(accountDir, 'msg', 'attach') if (!existsSync(attachDir)) { console.log('[ImageDecrypt] attach dir not found:', attachDir) return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName) } const searchKeys = this.uniq([normalizedMd5, normalizedDatName]) if (searchKeys.length === 0) return null for (const key of searchKeys) { const directHit = this.fastProbabilisticSearch(attachDir, key) if (directHit) return directHit } const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0]) if (legacyHit) return legacyHit console.log('[ImageDecrypt] findImageFile miss for:', searchKeys) return null } private fastProbabilisticSearch(attachDir: string, datName: string): string | null { const normalized = this.normalizeDatBase(datName) if (!normalized) return null const variants = this.buildPreferredDatNames(normalized) if (/^[a-f0-9]{32}$/.test(normalized)) { const dir1 = normalized.substring(0, 2) const dir2 = normalized.substring(2, 4) for (const variant of variants) { const candidates = [ join(attachDir, dir1, dir2, variant), join(attachDir, dir1, dir2, 'Img', variant), join(attachDir, dir1, dir2, 'Image', variant), join(attachDir, dir1, dir2, 'image', variant) ] const found = candidates.find((candidate) => existsSync(candidate)) if (found) { console.log('[ImageDecrypt] prefix path hit:', found) return found } } } try { const sessionDirs = readdirSync(attachDir).filter( (name) => name.length === 32 && /^[a-f0-9]+$/i.test(name) ) const now = new Date() const months: string[] = [] for (let i = 0; i < 24; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1) months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) } for (const sessDir of sessionDirs) { for (const month of months) { for (const sub of ['Img', 'Image', 'image']) { const imgDir = join(attachDir, sessDir, month, sub) if (!existsSync(imgDir)) continue const found = variants .map((variant) => join(imgDir, variant)) .find((candidate) => existsSync(candidate)) if (found) { console.log('[ImageDecrypt] found at:', found) return found } } } } } catch (e) { console.log('[ImageDecrypt]遍历目录失败:', e) } return null } private findImageFileInLegacyDirs(accountDir: string, datName: string): string | null { const normalized = this.normalizeDatBase(datName) if (!normalized) return null const roots = [ join(accountDir, 'FileStorage', 'Image'), join(accountDir, 'FileStorage', 'Image2'), join(accountDir, 'FileStorage', 'MsgImg') ].filter((root) => existsSync(root)) for (const root of roots) { const found = this.recursiveFindDat(root, normalized, 5) if (found) return found } return null } private recursiveFindDat(dir: string, datName: string, depth: number): string | null { if (depth < 0) return null try { const variants = new Set(this.buildPreferredDatNames(datName)) const entries = readdirSync(dir) for (const entry of entries) { const fullPath = join(dir, entry) const stat = statSync(fullPath) if (stat.isFile() && variants.has(entry.toLowerCase())) { console.log('[ImageDecrypt] legacy path hit:', fullPath) return fullPath } } for (const entry of entries) { const fullPath = join(dir, entry) if (!statSync(fullPath).isDirectory()) continue const found = this.recursiveFindDat(fullPath, datName, depth - 1) if (found) return found } } catch { return null } return null } /** * 解密图片文件并返回 Buffer */ decryptImage(datPath: string): Buffer | null { if (!existsSync(datPath)) { console.log('[ImageDecrypt] file not found:', datPath) return null } try { const version = this.getDatVersion(datPath) console.log( '[ImageDecrypt] dat version:', version, 'file:', datPath, 'aesKey present:', !!this.aesKey ) let decrypted: Buffer if (version === 1) { console.log('[ImageDecrypt] using V1 (default AES key)') const key = Buffer.from(this.defaultV1AesKey, 'ascii') decrypted = this.decryptDatV4(datPath, key) } else if (version === 2) { console.log('[ImageDecrypt] using V2 (user AES key)') if (!this.aesKey) { console.log('[ImageDecrypt] no AES key configured') return null } const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16) decrypted = this.decryptDatV4(datPath, key) } else { console.log('[ImageDecrypt] unsupported dat version:', version) return null } return decrypted } catch (error) { console.error('[ImageDecrypt] decrypt error:', error) return null } } /** * 将解密后的图片转换为 base64 */ decryptImageToBase64(datPath: string): string | null { if (!extname(datPath).toLowerCase().includes('dat')) { const data = readFileSync(datPath) const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase() const mimeType = this.getMimeType(ext) return `data:${mimeType};base64,${data.toString('base64')}` } const decrypted = this.decryptImage(datPath) if (!decrypted) return null const unwrapped = this.unwrapWxgf(decrypted) const ext = this.detectImageExtension(unwrapped) if (!ext) { console.log('[ImageDecrypt] unknown image format') return null } const mimeType = this.getMimeType(ext) return `data:${mimeType};base64,${unwrapped.toString('base64')}` } /** * 检测 DAT 文件版本 */ private getDatVersion(inputPath: string): number { const bytes = readFileSync(inputPath) if (bytes.length < 6) { return 0 } const signature = bytes.subarray(0, 6) if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) { return 1 } if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) { return 2 } return 0 } /** * V4 解密 - AES + XOR */ private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer { const bytes = readFileSync(inputPath) if (bytes.length < 0x0f) { throw new Error('文件太小,无法解析') } const header = bytes.subarray(0, 0x0f) const data = bytes.subarray(0x0f) const aesSize = this.bytesToInt32(header.subarray(6, 10)) const xorSize = this.bytesToInt32(header.subarray(10, 14)) // 对齐 AES 数据到 16 字节边界 const remainder = ((aesSize % 16) + 16) % 16 const alignedAesSize = aesSize + (16 - remainder) if (alignedAesSize > data.length) { throw new Error('文件格式异常:AES 数据长度超过文件实际长度') } // 解密 AES 数据 const aesData = data.subarray(0, alignedAesSize) let unpadded: Buffer = Buffer.alloc(0) if (aesData.length > 0) { const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) unpadded = this.strictRemovePadding(decrypted) } // 解密 XOR 数据 const remaining = data.subarray(alignedAesSize) if (xorSize < 0 || xorSize > remaining.length) { throw new Error('文件格式异常:XOR 数据长度不合法') } let rawData: Buffer let xoredData: Buffer if (xorSize > 0) { const rawLength = remaining.length - xorSize if (rawLength < 0) { throw new Error('文件格式异常:原始数据长度小于XOR长度') } rawData = remaining.subarray(0, rawLength) const xorData = remaining.subarray(rawLength) xoredData = Buffer.alloc(xorData.length) for (let i = 0; i < xorData.length; i += 1) { xoredData[i] = xorData[i] ^ this.xorKey } } else { rawData = remaining xoredData = Buffer.alloc(0) } return Buffer.concat([unpadded, rawData, xoredData]) } /** * 检测图片扩展名 */ private detectImageExtension(buffer: Buffer): string | null { if (buffer.length < 4) return null const SIGNATURES: Record = { '.jpg': Buffer.from([0xff, 0xd8, 0xff]), '.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]), '.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]), '.bmp': Buffer.from([0x42, 0x4d]), '.webp': Buffer.from([0x52, 0x49, 0x46, 0x46]) } for (const [ext, sig] of Object.entries(SIGNATURES)) { if (this.compareBytes(buffer.subarray(0, sig.length), sig)) { return ext } } return null } private getMimeType(ext: string): string { const mimeTypes: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp' } return mimeTypes[ext] || 'image/jpeg' } private normalizeDatBase(value: string): string { const lower = String(value || '') .trim() .toLowerCase() if (!lower) return '' const file = lower.split('/').pop()?.split('\\').pop() || lower const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase() } private buildPreferredDatNames(baseName: string): string[] { const base = this.normalizeDatBase(baseName) if (!base) return [] return [ `${base}_h.dat`, `${base}.dat`, `${base}_hd.dat`, `${base}_c.dat`, `${base}_t.dat`, `${base}.thumb.dat`, `${base}_thumb.dat` ] } private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string { const actualDir = dirname(inputPath) const base = this.normalizeDatBase(basename(inputPath)) const variants = this.buildPreferredDatNames(base) const ordered = allowThumbnail ? variants : variants.filter((name) => !this.isThumbnailName(name)) for (const variant of ordered) { const candidate = join(actualDir, variant) if (existsSync(candidate)) return candidate } return inputPath } private isThumbnailName(fileName: string): boolean { const lower = fileName.toLowerCase() return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') } private unwrapWxgf(buffer: Buffer): Buffer { if ( buffer.length < 20 || buffer[0] !== 0x77 || buffer[1] !== 0x78 || buffer[2] !== 0x67 || buffer[3] !== 0x66 ) { return buffer } for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) { if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { return buffer.subarray(i) } if ( buffer[i] === 0x89 && buffer[i + 1] === 0x50 && buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47 ) { return buffer.subarray(i) } } return buffer } private uniq(values: string[]): string[] { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) } private bytesToInt32(bytes: Buffer): number { return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) } private compareBytes(a: Buffer, b: Buffer): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false } return true } private strictRemovePadding(buffer: Buffer): Buffer { if (buffer.length === 0) return buffer const lastByte = buffer[buffer.length - 1] if (lastByte <= 16 && lastByte > 0) { const paddingLength = lastByte let valid = true for (let i = buffer.length - paddingLength; i < buffer.length; i++) { if (buffer[i] !== lastByte) { valid = false break } } if (valid) { return buffer.subarray(0, buffer.length - paddingLength) } } return buffer } }