Merge branch 'hicccc77:dev' into dev

This commit is contained in:
xuncha
2026-02-17 23:15:20 +08:00
committed by GitHub
10 changed files with 71 additions and 152 deletions

View File

@@ -35,6 +35,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
## 主要功能 ## 主要功能
- 本地实时查看聊天记录 - 本地实时查看聊天记录
- 朋友圈图片、视频、**实况**的预览和解密
- 统计分析与群聊画像 - 统计分析与群聊画像
- 年度报告与可视化概览 - 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式 - 导出聊天记录为 HTML 等格式
@@ -86,6 +87,7 @@ npm run build
## 致谢 ## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
## 支持我们 ## 支持我们

View File

@@ -14,7 +14,7 @@ interface ConfigSchema {
// 缓存相关 // 缓存相关
cachePath: string cachePath: string
weixinDllPath: string
lastOpenedDb: string lastOpenedDb: string
lastSession: string lastSession: string
@@ -75,7 +75,7 @@ export class ConfigService {
imageAesKey: '', imageAesKey: '',
wxidConfigs: {}, wxidConfigs: {},
cachePath: '', cachePath: '',
weixinDllPath: '',
lastOpenedDb: '', lastOpenedDb: '',
lastSession: '', lastSession: '',
theme: 'system', theme: 'system',

View File

@@ -99,23 +99,29 @@ export class Isaac64 {
this.isaac64(); this.isaac64();
this.randcnt = 256; this.randcnt = 256;
} }
return this.randrsl[256 - (this.randcnt--)]; return this.randrsl[--this.randcnt];
} }
/** /**
* Generates a keystream of the specified size (in bytes). * Generates a keystream where each 64-bit block is Big-Endian.
* @param size Size of the keystream in bytes (must be multiple of 8) * This matches WeChat's behavior (Reverse index order + byte reversal).
* @returns Buffer containing the keystream
*/ */
public generateKeystream(size: number): Buffer { public generateKeystreamBE(size: number): Buffer {
const stream = new BigUint64Array(size / 8); const buffer = Buffer.allocUnsafe(size);
for (let i = 0; i < stream.length; i++) { const fullBlocks = Math.floor(size / 8);
stream[i] = this.getNext();
for (let i = 0; i < fullBlocks; i++) {
buffer.writeBigUInt64BE(this.getNext(), i * 8);
} }
// WeChat's logic specifically reverses the entire byte array
const buffer = Buffer.from(stream.buffer); const remaining = size % 8;
// 注意:根据 worker.html 的逻辑,它是对 Uint8Array 执行 reverse() if (remaining > 0) {
// Array.from(wasmArray).reverse() const lastK = this.getNext();
return buffer.reverse(); const temp = Buffer.allocUnsafe(8);
temp.writeBigUInt64BE(lastK, 0);
temp.copy(buffer, fullBlocks * 8, 0, remaining);
}
return buffer;
} }
} }

View File

@@ -292,7 +292,6 @@ class SnsService {
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存) // 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`) const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
console.log(`[SnsService] 开始下载视频到临时文件: ${tmpPath}`)
try { try {
const https = require('https') const https = require('https')
@@ -325,7 +324,6 @@ class SnsService {
fileStream.on('finish', async () => { fileStream.on('finish', async () => {
fileStream.close() fileStream.close()
console.log(`[SnsService] 视频下载完成,开始解密... Key: ${key}`)
try { try {
const encryptedBuffer = await readFile(tmpPath) const encryptedBuffer = await readFile(tmpPath)
@@ -334,7 +332,6 @@ class SnsService {
if (key && String(key).trim().length > 0) { if (key && String(key).trim().length > 0) {
try { try {
console.log(`[SnsService] 使用 WASM Isaac64 解密视频... Key: ${key}`)
const keyText = String(key).trim() const keyText = String(key).trim()
let keystream: Buffer let keystream: Buffer
@@ -344,9 +341,8 @@ class SnsService {
keystream = await wasmService.getKeystream(keyText, 131072) keystream = await wasmService.getKeystream(keyText, 131072)
} catch (wasmErr) { } catch (wasmErr) {
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64 // 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
console.warn(`[SnsService] WASM 解密不可用,回退 Isaac64: ${wasmErr}`)
const isaac = new Isaac64(keyText) const isaac = new Isaac64(keyText)
keystream = isaac.generateKeystream(131072) keystream = isaac.generateKeystreamBE(131072)
} }
const decryptLen = Math.min(keystream.length, raw.length) const decryptLen = Math.min(keystream.length, raw.length)
@@ -358,23 +354,16 @@ class SnsService {
// 验证 MP4 签名 ('ftyp' at offset 4) // 验证 MP4 签名 ('ftyp' at offset 4)
const ftyp = raw.subarray(4, 8).toString('ascii') const ftyp = raw.subarray(4, 8).toString('ascii')
if (ftyp === 'ftyp') { if (ftyp !== 'ftyp') {
console.log(`[SnsService] 视频解密成功: ${url}`) // 可以在此处记录解密可能失败的标记,但不打印详细 hex
} else {
console.warn(`[SnsService] 视频解密可能失败: ${url}, 未找到 ftyp 签名: ${ftyp}`)
// 打印前 32 字节用于调试
console.warn(`[SnsService] Decrypted Header (first 32 bytes): ${raw.subarray(0, 32).toString('hex')}`)
} }
} catch (err) { } catch (err) {
console.error(`[SnsService] 视频解密出错: ${err}`) console.error(`[SnsService] 视频解密出错: ${err}`)
} }
} else {
console.warn(`[SnsService] 未提供 Key跳过解密直接保存`)
} }
// 写入最终缓存 (覆盖) // 写入最终缓存 (覆盖)
await writeFile(cachePath, raw) await writeFile(cachePath, raw)
console.log(`[SnsService] 视频已保存到缓存: ${cachePath}`)
// 删除临时文件 // 删除临时文件
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { } try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
@@ -444,8 +433,24 @@ class SnsService {
// 图片逻辑 // 图片逻辑
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
if (shouldDecrypt) { if (shouldDecrypt) {
const decrypted = await wcdbService.decryptSnsImage(raw, String(key)) try {
decoded = Buffer.from(decrypted) const keyStr = String(key).trim()
if (/^\d+$/.test(keyStr)) {
// 使用 WASM 版本的 Isaac64 解密图片
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
const wasmService = WasmService.getInstance()
const keystream = await wasmService.getKeystream(keyStr, raw.length)
const decrypted = Buffer.allocUnsafe(raw.length)
for (let i = 0; i < raw.length; i++) {
decrypted[i] = raw[i] ^ keystream[i]
}
decoded = decrypted
}
} catch (e) {
console.error('[SnsService] TS Decrypt Error:', e)
}
} }
// 写入磁盘缓存 // 写入磁盘缓存

View File

@@ -46,7 +46,6 @@ export class WasmService {
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm'); const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
const jsPath = path.join(basePath, 'wasm_video_decode.js'); const jsPath = path.join(basePath, 'wasm_video_decode.js');
console.log('[WasmService] Loading WASM from:', wasmPath);
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) { if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
throw new Error(`WASM files not found at ${basePath}`); throw new Error(`WASM files not found at ${basePath}`);
@@ -88,7 +87,6 @@ export class WasmService {
// Define Module // Define Module
mockGlobal.Module = { mockGlobal.Module = {
onRuntimeInitialized: () => { onRuntimeInitialized: () => {
console.log("[WasmService] WASM Runtime Initialized");
this.wasmLoaded = true; this.wasmLoaded = true;
resolve(); resolve();
}, },
@@ -133,10 +131,24 @@ export class WasmService {
} }
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> { public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
// the global reverse() will cause a shift in alignment.
const alignSize = Math.ceil(size / 8) * 8;
const buffer = await this.getRawKeystream(key, alignSize);
// Reverse the entire aligned buffer
const reversed = new Uint8Array(buffer);
reversed.reverse();
// Return exactly the requested size from the beginning of the reversed stream.
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
return Buffer.from(reversed).subarray(0, size);
}
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
await this.init(); await this.init();
if (!this.module || !this.module.WxIsaac64) { if (!this.module || !this.module.WxIsaac64) {
// Fallback check for asm.WxIsaac64 logic if needed, but debug showed it on Module
if (this.module.asm && this.module.asm.WxIsaac64) { if (this.module.asm && this.module.asm.WxIsaac64) {
this.module.WxIsaac64 = this.module.asm.WxIsaac64; this.module.WxIsaac64 = this.module.asm.WxIsaac64;
} }
@@ -149,26 +161,19 @@ export class WasmService {
try { try {
this.capturedKeystream = null; this.capturedKeystream = null;
const isaac = new this.module.WxIsaac64(key); const isaac = new this.module.WxIsaac64(key);
isaac.generate(size); // This triggers the global.wasm_isaac_generate callback isaac.generate(size);
// Cleanup if possible? isaac.delete()?
// In worker code: p.decryptor.delete()
if (isaac.delete) { if (isaac.delete) {
isaac.delete(); isaac.delete();
} }
if (this.capturedKeystream) { if (this.capturedKeystream) {
// The worker_release.js logic does: return Buffer.from(this.capturedKeystream);
// p.decryptor_array.set(r.reverse())
// So the actual keystream is the REVERSE of what is passed to the callback.
const reversed = new Uint8Array(this.capturedKeystream);
reversed.reverse();
return Buffer.from(reversed);
} else { } else {
throw new Error('[WasmService] Failed to capture keystream (callback not called)'); throw new Error('[WasmService] Failed to capture keystream (callback not called)');
} }
} catch (error) { } catch (error) {
console.error('[WasmService] Error generating keystream:', error); console.error('[WasmService] Error generating raw keystream:', error);
throw error; throw error;
} }
} }

View File

@@ -66,7 +66,7 @@ export class WcdbCore {
private wcdbStopMonitorPipe: any = null private wcdbStopMonitorPipe: any = null
private monitorPipeClient: any = null private monitorPipeClient: any = null
private wcdbDecryptSnsImage: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
@@ -144,42 +144,7 @@ export class WcdbCore {
} }
} }
/**
* 解密朋友圈图片
*/
async decryptSnsImage(encryptedData: Buffer, key: string): Promise<Buffer> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return encryptedData
}
if (!this.wcdbDecryptSnsImage) return encryptedData
try {
if (!this.wcdbDecryptSnsImage) {
console.error('[WCDB] wcdbDecryptSnsImage func is null')
return encryptedData
}
const outPtr = [null as any]
// Koffi pass Buffer as char* pointer
const result = this.wcdbDecryptSnsImage(encryptedData, encryptedData.length, key, outPtr)
if (result === 0 && outPtr[0]) {
const hex = this.decodeJsonPtr(outPtr[0])
if (hex) {
return Buffer.from(hex, 'hex')
}
} else {
console.error(`[WCDB] Decrypt SNS image failed with code: ${result}`)
// 主动获取 DLL 内部日志以诊断问题
await this.printLogs(true)
}
} catch (e) {
console.error('解密图片失败:', e)
}
return encryptedData
}
stopMonitor(): void { stopMonitor(): void {
if (this.monitorPipeClient) { if (this.monitorPipeClient) {
@@ -602,12 +567,7 @@ export class WcdbCore {
this.wcdbVerifyUser = null this.wcdbVerifyUser = null
} }
// wcdb_status wcdb_decrypt_sns_image(const char* encrypted_data, int32_t data_len, const char* key, char** out_hex)
try {
this.wcdbDecryptSnsImage = this.lib.func('int32 wcdb_decrypt_sns_image(const char* data, int32 len, const char* key, _Out_ void** outHex)')
} catch {
this.wcdbDecryptSnsImage = null
}
// 初始化 // 初始化
const initResult = this.wcdbInit() const initResult = this.wcdbInit()

View File

@@ -431,12 +431,7 @@ export class WcdbService {
return this.callWorker('verifyUser', { message, hwnd }) return this.callWorker('verifyUser', { message, hwnd })
} }
/**
* 解密朋友圈图片
*/
async decryptSnsImage(encryptedData: Buffer, key: string): Promise<Buffer> {
return this.callWorker<Buffer>('decryptSnsImage', { encryptedData, key })
}
} }

View File

@@ -150,9 +150,7 @@ if (parentPort) {
case 'verifyUser': case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd) result = await core.verifyUser(payload.message, payload.hwnd)
break break
case 'decryptSnsImage':
result = await core.decryptSnsImage(payload.encryptedData, payload.key)
break
default: default:
result = { success: false, error: `Unknown method: ${type}` } result = { success: false, error: `Unknown method: ${type}` }
} }

View File

@@ -74,7 +74,7 @@ function SettingsPage() {
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null) const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null) const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [weixinDllPath, setWeixinDllPath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('') const [whisperModelDir, setWhisperModelDir] = useState('')
@@ -250,7 +250,7 @@ function SettingsPage() {
const savedPath = await configService.getDbPath() const savedPath = await configService.getDbPath()
const savedWxid = await configService.getMyWxid() const savedWxid = await configService.getMyWxid()
const savedCachePath = await configService.getCachePath() const savedCachePath = await configService.getCachePath()
const savedWeixinDllPath = await configService.getWeixinDllPath()
const savedExportPath = await configService.getExportPath() const savedExportPath = await configService.getExportPath()
const savedLogEnabled = await configService.getLogEnabled() const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey() const savedImageXorKey = await configService.getImageXorKey()
@@ -279,7 +279,7 @@ function SettingsPage() {
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedWeixinDllPath) setWeixinDllPath(savedWeixinDllPath)
const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? '' const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
@@ -616,29 +616,7 @@ function SettingsPage() {
await applyWxidSelection(selectedWxid) await applyWxidSelection(selectedWxid)
} }
const handleSelectWeixinDllPath = async () => {
try {
const result = await dialog.openFile({
title: '选择 Weixin.dll 文件',
properties: ['openFile'],
filters: [{ name: 'DLL', extensions: ['dll'] }]
})
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
setWeixinDllPath(selectedPath)
await configService.setWeixinDllPath(selectedPath)
showMessage('已选择 Weixin.dll 路径', true)
}
} catch {
showMessage('选择 Weixin.dll 失败', false)
}
}
const handleResetWeixinDllPath = async () => {
setWeixinDllPath('')
await configService.setWeixinDllPath('')
showMessage('已清空 Weixin.dll 路径', true)
}
const handleSelectCachePath = async () => { const handleSelectCachePath = async () => {
try { try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
@@ -1332,28 +1310,7 @@ function SettingsPage() {
</div> </div>
</div> </div>
<div className="form-group">
<label>Weixin.dll <span className="optional">()</span></label>
<span className="form-hint">线使 DLL</span>
<input
type="text"
placeholder="例如: D:\weixindata\Weixin\Weixin.dll"
value={weixinDllPath}
onChange={(e) => {
const value = e.target.value
setWeixinDllPath(value)
scheduleConfigSave('weixinDllPath', () => configService.setWeixinDllPath(value))
}}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWeixinDllPath}>
<FolderOpen size={16} />
</button>
<button className="btn btn-secondary" onClick={handleResetWeixinDllPath}>
</button>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label> wxid</label> <label> wxid</label>

View File

@@ -12,7 +12,7 @@ export const CONFIG_KEYS = {
LAST_SESSION: 'lastSession', LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds', WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath', CACHE_PATH: 'cachePath',
WEIXIN_DLL_PATH: 'weixinDllPath',
EXPORT_PATH: 'exportPath', EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted', AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled', LOG_ENABLED: 'logEnabled',
@@ -163,16 +163,7 @@ export async function setCachePath(path: string): Promise<void> {
} }
// 获取 Weixin.dll 路径
export async function getWeixinDllPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WEIXIN_DLL_PATH)
return value as string | null
}
// 设置 Weixin.dll 路径
export async function setWeixinDllPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.WEIXIN_DLL_PATH, path)
}
// 获取导出路径 // 获取导出路径
export async function getExportPath(): Promise<string | null> { export async function getExportPath(): Promise<string | null> {