mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
improvement(sns-media): 内置 WeFlow WASM 资源并切换朋友圈图片解密实现
- 内置 wasm_video_decode 资源并纳入打包 - Node helper 优先使用包内 weflow_wasm 目录 - 朋友圈图片远程解密改为走 WeFlow WxIsaac64 全量 XOR - 补充对应测试并更新纯 Python fallback 说明
This commit is contained in:
@@ -43,6 +43,9 @@ include = [
|
||||
"src/wechat_decrypt_tool/native/VoipEngine.dll",
|
||||
"src/wechat_decrypt_tool/native/wcdb_api.dll",
|
||||
"src/wechat_decrypt_tool/native/WCDB.dll",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -10,9 +10,9 @@ This module provides a pure-Python ISAAC-64 implementation so the backend can
|
||||
still attempt to generate a keystream when the WASM helper is unavailable.
|
||||
|
||||
Notes:
|
||||
- Moments *image* decryption is handled via `wcdb_api.dll` (`wcdb_decrypt_sns_image`)
|
||||
because "ISAAC-64 full-file XOR" is not reliably reproducible for images across
|
||||
different versions/samples.
|
||||
- Production Moments image/video decryption should prefer the vendored
|
||||
WxIsaac64/WASM path. This pure-Python implementation is only a fallback when
|
||||
Node/WASM is unavailable.
|
||||
- This ISAAC-64 implementation may not perfectly match WxIsaac64; treat it as
|
||||
best-effort.
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,122 @@
|
||||
// Generate WeChat/WeFlow WxIsaac64 keystream via the vendored WASM module.
|
||||
//
|
||||
// Usage:
|
||||
// node weflow_wasm_keystream.js <key> <size>
|
||||
//
|
||||
// Prints a base64-encoded keystream to stdout (no extra logs).
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
function usageAndExit() {
|
||||
process.stderr.write('Usage: node weflow_wasm_keystream.js <key> <size>\\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const key = String(process.argv[2] || '').trim()
|
||||
const size = Number(process.argv[3] || 0)
|
||||
|
||||
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
|
||||
|
||||
const basePath = __dirname
|
||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
|
||||
const jsPath = path.join(basePath, 'wasm_video_decode.js')
|
||||
|
||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||
process.stderr.write(`Vendored WASM assets not found: ${basePath}\\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wasmBinary = fs.readFileSync(wasmPath)
|
||||
const jsContent = fs.readFileSync(jsPath, 'utf8')
|
||||
|
||||
let capturedKeystream = null
|
||||
let resolveInit
|
||||
let rejectInit
|
||||
const initPromise = new Promise((res, rej) => {
|
||||
resolveInit = res
|
||||
rejectInit = rej
|
||||
})
|
||||
|
||||
const mockGlobal = {
|
||||
console: { log: () => {}, error: () => {} },
|
||||
Buffer,
|
||||
Uint8Array,
|
||||
Int8Array,
|
||||
Uint16Array,
|
||||
Int16Array,
|
||||
Uint32Array,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
BigInt64Array,
|
||||
BigUint64Array,
|
||||
Array,
|
||||
Object,
|
||||
Function,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Error,
|
||||
Promise,
|
||||
require,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
}
|
||||
|
||||
mockGlobal.Module = {
|
||||
onRuntimeInitialized: () => resolveInit(),
|
||||
wasmBinary,
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
}
|
||||
|
||||
mockGlobal.self = mockGlobal
|
||||
mockGlobal.self.location = { href: jsPath }
|
||||
mockGlobal.WorkerGlobalScope = function () {}
|
||||
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
|
||||
|
||||
mockGlobal.wasm_isaac_generate = (ptr, n) => {
|
||||
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
|
||||
capturedKeystream = new Uint8Array(buf)
|
||||
}
|
||||
|
||||
try {
|
||||
const context = vm.createContext(mockGlobal)
|
||||
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
|
||||
} catch (e) {
|
||||
rejectInit(e)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await initPromise
|
||||
|
||||
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
|
||||
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
|
||||
}
|
||||
if (!mockGlobal.Module.WxIsaac64) {
|
||||
throw new Error('WxIsaac64 not found in WASM module')
|
||||
}
|
||||
|
||||
const alignedSize = Math.ceil(size / 8) * 8
|
||||
|
||||
capturedKeystream = null
|
||||
const isaac = new mockGlobal.Module.WxIsaac64(key)
|
||||
isaac.generate(alignedSize)
|
||||
if (isaac.delete) isaac.delete()
|
||||
|
||||
if (!capturedKeystream) throw new Error('Failed to capture keystream')
|
||||
|
||||
const out = Buffer.from(capturedKeystream)
|
||||
out.reverse()
|
||||
process.stdout.write(out.subarray(0, size).toString('base64'))
|
||||
} catch (e) {
|
||||
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
@@ -8,8 +8,8 @@ so it can be reused by:
|
||||
- Offline export (`sns_export_service.py`)
|
||||
|
||||
Important notes (empirical, matches current repo behavior):
|
||||
- SNS images: prefer `wcdb_api.dll` export `wcdb_decrypt_sns_image` (black-box). Pure ISAAC64
|
||||
keystream XOR is NOT reliable for images across versions.
|
||||
- SNS images: match WeFlow's Electron implementation by generating the WxIsaac64
|
||||
keystream from WASM and XORing the full payload in-memory.
|
||||
- SNS videos: encrypted only for the first 128KB; decrypt via WeFlow's WxIsaac64 (WASM keystream)
|
||||
and XOR in-place.
|
||||
"""
|
||||
@@ -31,9 +31,11 @@ import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .logging_config import get_logger
|
||||
from .wcdb_realtime import decrypt_sns_image as _wcdb_decrypt_sns_image
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
_NATIVE_DIR = _PACKAGE_DIR / "native"
|
||||
_WEFLOW_WASM_DIR = _NATIVE_DIR / "weflow_wasm"
|
||||
|
||||
|
||||
def is_allowed_sns_media_host(host: str) -> bool:
|
||||
@@ -96,11 +98,16 @@ def _detect_mp4_ftyp(head: bytes) -> bool:
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _weflow_wxisaac64_script_path() -> str:
|
||||
"""Locate the Node helper that wraps WeFlow's wasm_video_decode.* assets."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
script = repo_root / "tools" / "weflow_wasm_keystream.js"
|
||||
if script.exists() and script.is_file():
|
||||
return str(script)
|
||||
"""Locate the bundled Node helper that wraps the vendored wasm_video_decode.* assets."""
|
||||
bundled = _WEFLOW_WASM_DIR / "weflow_wasm_keystream.js"
|
||||
if bundled.exists() and bundled.is_file():
|
||||
return str(bundled)
|
||||
|
||||
# Development fallback: allow the repo-level helper to proxy into the vendored assets.
|
||||
repo_root = _PACKAGE_DIR.parents[1]
|
||||
legacy = repo_root / "tools" / "weflow_wasm_keystream.js"
|
||||
if legacy.exists() and legacy.is_file():
|
||||
return str(legacy)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -416,6 +423,24 @@ def detect_image_mime(data: bytes) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def weflow_decrypt_sns_image_bytes(payload: bytes, key: str) -> bytes:
|
||||
"""Decrypt a Moments image with the same full-file XOR flow that WeFlow uses."""
|
||||
raw = bytes(payload or b"")
|
||||
key_text = str(key or "").strip()
|
||||
if not raw or not key_text:
|
||||
return raw
|
||||
|
||||
ks = weflow_wxisaac64_keystream(key_text, len(raw))
|
||||
if not ks:
|
||||
return raw
|
||||
|
||||
out = bytearray(raw)
|
||||
n = min(len(out), len(ks))
|
||||
for i in range(n):
|
||||
out[i] ^= ks[i]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
_SNS_REMOTE_CACHE_EXTS = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -558,7 +583,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
|
||||
token: str,
|
||||
use_cache: bool,
|
||||
) -> Optional[SnsRemoteImageResult]:
|
||||
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
|
||||
"""Try WeFlow-style: download from CDN -> WxIsaac64 full-file XOR -> return bytes.
|
||||
|
||||
Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to
|
||||
local cache matching logic.
|
||||
@@ -652,7 +677,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
|
||||
|
||||
if need_decrypt:
|
||||
try:
|
||||
decoded2 = _wcdb_decrypt_sns_image(raw, k)
|
||||
decoded2 = weflow_decrypt_sns_image_bytes(raw, k)
|
||||
mt2 = detect_image_mime(decoded2)
|
||||
if mt2:
|
||||
decoded = decoded2
|
||||
|
||||
+16
-3
@@ -15,6 +15,20 @@ from wechat_decrypt_tool import sns_media # noqa: E402 pylint: disable=wrong-i
|
||||
|
||||
|
||||
class TestSnsMedia(unittest.TestCase):
|
||||
def test_weflow_wxisaac64_script_path_uses_bundled_helper(self):
|
||||
sns_media._weflow_wxisaac64_script_path.cache_clear()
|
||||
script = sns_media._weflow_wxisaac64_script_path()
|
||||
self.assertTrue(script)
|
||||
|
||||
script_path = Path(script)
|
||||
normalized = script.replace("\\", "/")
|
||||
self.assertTrue(script_path.exists())
|
||||
self.assertEqual(script_path.name, "weflow_wasm_keystream.js")
|
||||
self.assertIn("/src/wechat_decrypt_tool/native/weflow_wasm/", normalized)
|
||||
self.assertNotIn("/WeFlow/", normalized)
|
||||
self.assertTrue((script_path.parent / "wasm_video_decode.js").exists())
|
||||
self.assertTrue((script_path.parent / "wasm_video_decode.wasm").exists())
|
||||
|
||||
def test_fix_sns_cdn_url_image_rewrites_150_and_appends_token(self):
|
||||
u = "http://mmsns.qpic.cn/sns/abc/150"
|
||||
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False)
|
||||
@@ -131,7 +145,7 @@ class TestSnsMedia(unittest.TestCase):
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded):
|
||||
res = asyncio.run(
|
||||
sns_media.try_fetch_and_decrypt_sns_image_remote(
|
||||
account_dir=account_dir,
|
||||
@@ -161,7 +175,7 @@ class TestSnsMedia(unittest.TestCase):
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded_bad):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded_bad):
|
||||
res = asyncio.run(
|
||||
sns_media.try_fetch_and_decrypt_sns_image_remote(
|
||||
account_dir=account_dir,
|
||||
@@ -177,4 +191,3 @@ class TestSnsMedia(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -1,122 +1,2 @@
|
||||
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/weflow_wasm_keystream.js <key> <size>
|
||||
//
|
||||
// Prints a base64-encoded keystream to stdout (no extra logs).
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
function usageAndExit() {
|
||||
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const key = String(process.argv[2] || '').trim()
|
||||
const size = Number(process.argv[3] || 0)
|
||||
|
||||
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
|
||||
|
||||
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
|
||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
|
||||
const jsPath = path.join(basePath, 'wasm_video_decode.js')
|
||||
|
||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wasmBinary = fs.readFileSync(wasmPath)
|
||||
const jsContent = fs.readFileSync(jsPath, 'utf8')
|
||||
|
||||
let capturedKeystream = null
|
||||
let resolveInit
|
||||
let rejectInit
|
||||
const initPromise = new Promise((res, rej) => {
|
||||
resolveInit = res
|
||||
rejectInit = rej
|
||||
})
|
||||
|
||||
const mockGlobal = {
|
||||
console: { log: () => {}, error: () => {} }, // keep stdout clean
|
||||
Buffer,
|
||||
Uint8Array,
|
||||
Int8Array,
|
||||
Uint16Array,
|
||||
Int16Array,
|
||||
Uint32Array,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
BigInt64Array,
|
||||
BigUint64Array,
|
||||
Array,
|
||||
Object,
|
||||
Function,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Error,
|
||||
Promise,
|
||||
require,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
}
|
||||
|
||||
mockGlobal.Module = {
|
||||
onRuntimeInitialized: () => resolveInit(),
|
||||
wasmBinary,
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
}
|
||||
|
||||
mockGlobal.self = mockGlobal
|
||||
mockGlobal.self.location = { href: jsPath }
|
||||
mockGlobal.WorkerGlobalScope = function () {}
|
||||
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
|
||||
|
||||
mockGlobal.wasm_isaac_generate = (ptr, n) => {
|
||||
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
|
||||
capturedKeystream = new Uint8Array(buf) // copy view
|
||||
}
|
||||
|
||||
try {
|
||||
const context = vm.createContext(mockGlobal)
|
||||
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
|
||||
} catch (e) {
|
||||
rejectInit(e)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await initPromise
|
||||
|
||||
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
|
||||
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
|
||||
}
|
||||
if (!mockGlobal.Module.WxIsaac64) {
|
||||
throw new Error('WxIsaac64 not found in WASM module')
|
||||
}
|
||||
|
||||
capturedKeystream = null
|
||||
const isaac = new mockGlobal.Module.WxIsaac64(key)
|
||||
isaac.generate(size)
|
||||
if (isaac.delete) isaac.delete()
|
||||
|
||||
if (!capturedKeystream) throw new Error('Failed to capture keystream')
|
||||
|
||||
const out = Buffer.from(capturedKeystream)
|
||||
// Match WeFlow worker logic: reverse the captured Uint8Array.
|
||||
out.reverse()
|
||||
process.stdout.write(out.toString('base64'))
|
||||
} catch (e) {
|
||||
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
require(path.join(__dirname, '..', 'src', 'wechat_decrypt_tool', 'native', 'weflow_wasm', 'weflow_wasm_keystream.js'))
|
||||
|
||||
Reference in New Issue
Block a user