improvement(sns-media): 内置 WeFlow WASM 资源并切换朋友圈图片解密实现

- 内置 wasm_video_decode 资源并纳入打包
- Node helper 优先使用包内 weflow_wasm 目录
- 朋友圈图片远程解密改为走 WeFlow WxIsaac64 全量 XOR
- 补充对应测试并更新纯 Python fallback 说明
This commit is contained in:
2977094657
2026-04-16 01:18:27 +08:00
parent 3b34e786f1
commit 94a8b124c3
8 changed files with 4887 additions and 137 deletions
+3
View File
@@ -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]
+3 -3
View File
@@ -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
@@ -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)
}
})()
+35 -10
View File
@@ -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
View File
@@ -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 -121
View File
@@ -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'))