mirror of
https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption.git
synced 2026-10-25 17:36:33 +08:00
fix: 修复大文件解密崩溃并将 XOR 解密移至服务端
问题修复: - 修复解密超过 80MB 视频时页面崩溃的问题(issue #4 #6 #8) - 根因:旧实现将整段视频经 CDP 管道传入浏览器做 XOR,base64 膨胀后超过 Chromium DevTools 协议 100MB 单条消息上限,导致页面被关闭 - 将 XOR 解密从浏览器移至 Node.js 端,浏览器仅负责生成 128KB 密钥流, 视频数据不再进入浏览器,文件大小不再受此限制 新功能: - 新增 lib/decrypt.js 解密核心模块(decryptBuffer / assertMp4) - 新增 decode_key 密钥流 LRU 缓存,命中后跳过浏览器 WASM 调用, 可通过 KEYSTREAM_CACHE_MAX 环境变量配置上限(默认100) - 新增 test/decrypt.test.js 单元测试,覆盖 120MB 大文件边界场景 其他: - Dockerfile 补充拷贝 lib 目录,修复容器内模块缺失 - worker.html 的 decryptVideo 标注为已弃用(保留向后兼容) - 更新 README 架构说明,补充大文件故障排除与「如何获取 decode_key」说明 - 版本号升级至 2.1.0
This commit is contained in:
1 parent
9f0c8a5def
commit
8900a7c6fb
8 files changed
+366
-102
No files matched your search
@@ -205,3 +205,8 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
/todo/
|
||||
|
||||
# api-service 为 Node.js 子项目,其源码目录不应被 Python 模板的 lib/ 规则忽略
|
||||
!api-service/lib/
|
||||
!api-service/lib/**
|
||||
@@ -63,6 +63,7 @@ COPY --from=builder /root/.cache/ms-playwright /ms-playwright
|
||||
COPY server.js .
|
||||
COPY worker.html .
|
||||
COPY docs.html .
|
||||
COPY lib ./lib
|
||||
COPY wechat_files ./wechat_files
|
||||
|
||||
# Create non-root user for security
|
||||
|
||||
+98
-19
@@ -15,6 +15,37 @@
|
||||
- **健康检查**: 内置服务健康监控
|
||||
- **大文件支持**: 最大支持 500MB 视频文件
|
||||
|
||||
## 如何获取 decode_key?(常见问题)
|
||||
|
||||
> 对应 issue #1 / #9。**本仓库只负责解密,不负责抓取微信接口。**
|
||||
|
||||
`decode_key` 和加密视频 URL 都来自**微信原始接口的响应 JSON**,结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"object_desc": {
|
||||
"media": [{
|
||||
"decode_key": "2136343393", // 解密种子(即本服务的 decode_key)
|
||||
"url": "https://...", // 加密视频下载链接
|
||||
"file_size": 14088528
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
获取途径(任选其一):
|
||||
|
||||
1. **第三方接口**:例如 `api.tikhub.io` 的视频号详情接口,响应直接包含
|
||||
`decode_key` 与加密视频 URL。
|
||||
2. **自行抓包**:在 PC 端微信安装 SSL 证书抓包,打开视频号并搜索关键字,
|
||||
即可在接口响应 JSON 中找到 `decode_key` 和对应加密视频 URL。
|
||||
> 注意:微信部分流量走私有协议 mmtls,普通 HTTPS 抓包不一定能拿到,需视
|
||||
> 客户端版本与抓包方案而定。
|
||||
|
||||
拿到 `decode_key` 和加密视频文件后,再调用本服务 `POST /api/decrypt` 解密。
|
||||
|
||||
## 架构说明
|
||||
|
||||
```
|
||||
@@ -27,7 +58,8 @@
|
||||
│ Express.js API Server │
|
||||
│ (Node.js + Multer + CORS) │
|
||||
└───────────────────┬─────────────────────────────────┘
|
||||
│ RPC Call via page.evaluate()
|
||||
│ ① RPC:仅生成 128KB 密钥流
|
||||
│ page.evaluate(generateKeystream)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Playwright Chromium Browser │
|
||||
@@ -39,13 +71,24 @@
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ RPC Functions: │ │
|
||||
│ │ - generateKeystream(decodeKey) │ │
|
||||
│ │ - decryptVideo(encrypted, keystream) │ │
|
||||
│ │ - generateKeystream(decodeKey) ← 唯一职责 │ │
|
||||
│ │ - checkWasmStatus() │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
└───────────────────┬─────────────────────────────────┘
|
||||
│ ② 返回 128KB 密钥流
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ③ Node.js 端 XOR 解密(lib/decrypt.js) │
|
||||
│ 视频整段 Buffer 不进浏览器 → 规避 CDP 100MB 上限 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **v2.1 架构变更(修复大文件解密崩溃)**:旧版把整段视频经 CDP 管道
|
||||
> 传入浏览器做 XOR,base64 膨胀后超过 Chromium DevTools 协议 100MB 单条
|
||||
> 消息上限,导致 80MB+ 文件解密时页面崩溃(issue #4 / #6 / #8)。现在
|
||||
> 浏览器只负责生成 128KB 密钥流,XOR 解密改在 Node.js 端用 Buffer 完成,
|
||||
> 文件大小不再受此限制。
|
||||
|
||||
### 为什么选择 Playwright?
|
||||
|
||||
WeChat 的 WASM 模块依赖浏览器特定的 API (`fetch`, `self`, `window` 等),无法直接在 Node.js 环境中运行。Playwright 方案的优势:
|
||||
@@ -129,7 +172,7 @@ GET /
|
||||
```json
|
||||
{
|
||||
"service": "WeChat Channels Video Decryption API",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"engine": "Playwright + Chromium",
|
||||
"author": "Evil0ctal",
|
||||
"endpoints": {
|
||||
@@ -153,7 +196,7 @@ GET /health
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "wechat-decrypt-api",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"engine": "playwright",
|
||||
"wasm": {
|
||||
"loaded": true,
|
||||
@@ -428,19 +471,22 @@ const keystreamBase64 = await page.evaluate(async (key) => {
|
||||
|
||||
**关键**: 使用 `http://` 而非 `file://` 协议,避免浏览器 CORS 限制,使本地 WASM 文件加载成功。
|
||||
|
||||
### 3. 数据传输
|
||||
### 3. 数据传输(v2.1:视频不进浏览器)
|
||||
|
||||
使用 Base64 编码在 Node.js 和浏览器之间传输二进制数据:
|
||||
浏览器与 Node.js 之间**只传输 128KB 密钥流**,加密视频本身始终留在 Node.js 端:
|
||||
|
||||
```javascript
|
||||
// Node.js → Browser
|
||||
const encryptedBase64 = videoFile.buffer.toString('base64');
|
||||
|
||||
// Browser → Node.js
|
||||
const decryptedBase64 = await page.evaluate(...);
|
||||
const decrypted = Buffer.from(decryptedBase64, 'base64');
|
||||
// Browser → Node.js:仅密钥流(base64,约 175KB,远小于 CDP 100MB 上限)
|
||||
const keystreamBase64 = await page.evaluate(
|
||||
async (key) => await window.generateKeystream(key),
|
||||
decode_key
|
||||
);
|
||||
const keystream = Buffer.from(keystreamBase64, 'base64');
|
||||
```
|
||||
|
||||
> 旧版会把整段视频 `toString('base64')` 传入浏览器,80MB 文件膨胀到约
|
||||
> 107MB,超过 CDP 管道 100MB 上限而导致页面崩溃。v2.1 不再传输视频数据。
|
||||
|
||||
### 4. Isaac64 密钥流生成
|
||||
|
||||
WASM 模块实现了微信魔改的 Isaac64 算法:
|
||||
@@ -455,16 +501,25 @@ window.wasm_isaac_generate = function(ptr, size) {
|
||||
|
||||
**重要**: 密钥流必须反转 (`reverse()`) 才能正确解密,这是微信特有的实现细节。
|
||||
|
||||
### 5. XOR 解密
|
||||
### 5. XOR 解密(v2.1:Node.js 端执行)
|
||||
|
||||
前 128KB 数据通过 XOR 操作解密:
|
||||
微信仅加密文件**前 128KB**,其余为明文。XOR 解密在 Node.js 端用 Buffer 完成
|
||||
(`lib/decrypt.js` 的 `decryptBuffer`),无需把视频送进浏览器:
|
||||
|
||||
```javascript
|
||||
for (let i = 0; i < 131072 && i < encrypted.length; i++) {
|
||||
decrypted[i] = encrypted[i] ^ keystream[i];
|
||||
function decryptBuffer(encrypted, keystream) {
|
||||
const decrypted = Buffer.from(encrypted); // 明文部分原样保留
|
||||
const decryptLen = Math.min(KEYSTREAM_SIZE, encrypted.length, keystream.length);
|
||||
for (let i = 0; i < decryptLen; i++) {
|
||||
decrypted[i] = encrypted[i] ^ keystream[i];
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
```
|
||||
|
||||
该逻辑与 Python CLI(`decrypt_wechat_video_cli.py`)字节级一致,并有单元测试
|
||||
(`npm test`)覆盖含 120MB 大文件在内的边界场景。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题: WASM 模块加载超时
|
||||
@@ -506,6 +561,20 @@ docker-compose build --no-cache
|
||||
2. 检查 API 响应中的错误信息
|
||||
3. 验证解密文件的前 12 字节应为: `00 00 00 XX 66 74 79 70` (MP4 签名)
|
||||
|
||||
### 问题: 大文件(80MB+)解密报错 "Target page... has been closed"
|
||||
|
||||
**症状**(issue #4 / #6 / #8):
|
||||
```
|
||||
page.evaluate: Target page, context or browser has been closed
|
||||
Too large read data is pending: capacity=104857600 ...
|
||||
```
|
||||
|
||||
**原因**: 旧版(≤ v2.0)把整段视频经 CDP 管道传入浏览器做 XOR,base64 膨胀后
|
||||
超过 Chromium DevTools 协议 100MB 单条消息上限,导致页面崩溃。
|
||||
|
||||
**解决方案**: 升级到 **v2.1+**。XOR 解密已移至 Node.js 端,浏览器只生成 128KB
|
||||
密钥流,文件大小不再受此限制(仅受 multer 500MB 与服务器内存约束)。
|
||||
|
||||
### 问题: 文件上传失败 (413 错误)
|
||||
|
||||
**症状**:
|
||||
@@ -537,9 +606,11 @@ deploy:
|
||||
memory: 4G # 增加内存限制
|
||||
```
|
||||
|
||||
### 3. 启用请求缓存
|
||||
### 3. 密钥流缓存(v2.1 已内置)
|
||||
|
||||
对于相同的 decode_key,可以缓存生成的密钥流以提高性能。
|
||||
相同 `decode_key` 的密钥流恒定,服务端已内置 LRU 缓存:命中后跳过浏览器 WASM
|
||||
调用(并发接口连页面池都不占用)。缓存上限可通过环境变量 `KEYSTREAM_CACHE_MAX`
|
||||
配置(默认 100)。
|
||||
|
||||
### 4. 负载均衡
|
||||
|
||||
@@ -569,6 +640,14 @@ Evil0ctal - evil0ctal1985@gmail.com
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.1.0 (2026-06-09)
|
||||
- **修复大文件解密崩溃**(issue #4 / #6 / #8):XOR 解密从浏览器移至 Node.js 端,
|
||||
视频数据不再经 CDP 管道传输,规避 DevTools 协议 100MB 单条消息上限。80MB+ /
|
||||
100MB+ / 300MB+ 文件均可正常解密。
|
||||
- **新增密钥流 LRU 缓存**:相同 `decode_key` 跳过浏览器 WASM 调用,可经
|
||||
`KEYSTREAM_CACHE_MAX` 配置上限。
|
||||
- **解密核心抽离为 `lib/decrypt.js`** 并新增单元测试(`npm test`,含 120MB 边界场景)。
|
||||
|
||||
### v2.0.0 (2025-10-17)
|
||||
- 采用 Playwright + RPC 架构
|
||||
- 100% 兼容微信官方 WASM v1.2.46
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 视频解密核心(纯函数,Node.js 端执行)
|
||||
*
|
||||
* 微信视频号仅加密文件前 KEYSTREAM_SIZE(128KB) 字节,其余为明文。
|
||||
* 解密无需浏览器:浏览器只负责用 WASM 生成 128KB 密钥流,XOR 在此完成。
|
||||
* 旧实现把整段视频经 CDP 管道传入浏览器,会触发 DevTools 协议 100MB 单条
|
||||
* 消息上限,导致大文件解密时页面崩溃(见 issue #4 / #6 / #8)。
|
||||
*
|
||||
* @module lib/decrypt
|
||||
*/
|
||||
|
||||
// 微信加密作用的字节数(密钥流长度)
|
||||
const KEYSTREAM_SIZE = 131072;
|
||||
|
||||
/**
|
||||
* 对加密视频执行 XOR 解密
|
||||
* @param {Buffer} encrypted 原始加密视频 Buffer
|
||||
* @param {Buffer} keystream 128KB 密钥流 Buffer
|
||||
* @returns {Buffer} 解密后的视频 Buffer(明文部分原样保留)
|
||||
*/
|
||||
function decryptBuffer(encrypted, keystream) {
|
||||
const decrypted = Buffer.from(encrypted); // 拷贝一份,明文部分原样保留
|
||||
const decryptLen = Math.min(KEYSTREAM_SIZE, encrypted.length, keystream.length);
|
||||
for (let i = 0; i < decryptLen; i++) {
|
||||
decrypted[i] = encrypted[i] ^ keystream[i];
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验解密结果是否为合法 MP4(偏移 4 处应为 ftyp 签名)
|
||||
* @param {Buffer} buffer 解密后的视频 Buffer
|
||||
* @throws {Error} 当签名缺失时(通常是 decode_key 不匹配)
|
||||
*/
|
||||
function assertMp4(buffer) {
|
||||
const ftyp = buffer.toString('utf8', 4, 8);
|
||||
if (ftyp !== 'ftyp') {
|
||||
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KEYSTREAM_SIZE, decryptBuffer, assertMp4 };
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-decrypt-api-playwright",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "WeChat Channels Video Decryption API Service (Playwright Edition) - 100% compatible with WeChat official WASM module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "node test.js",
|
||||
"test": "node test/decrypt.test.js",
|
||||
"install-browsers": "npx playwright install chromium --with-deps"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
+115
-81
@@ -13,6 +13,7 @@ const { chromium } = require('playwright');
|
||||
const multer = require('multer');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const { KEYSTREAM_SIZE, decryptBuffer, assertMp4 } = require('./lib/decrypt');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -42,6 +43,12 @@ const getWorkerUrl = () => `http://localhost:${PORT}/worker.html`;
|
||||
const POOL_SIZE = parseInt(process.env.POOL_SIZE) || 3; // 默认3个并发页面
|
||||
let pagePool = null;
|
||||
|
||||
// 解密配置(KEYSTREAM_SIZE / decryptBuffer / assertMp4 见 ./lib/decrypt)
|
||||
// 密钥流缓存:同一 decode_key 生成的密钥流恒定,可复用以跳过浏览器 WASM 调用
|
||||
// 采用简单 LRU(依赖 Map 的插入顺序),上限可通过环境变量配置
|
||||
const KEYSTREAM_CACHE_MAX = parseInt(process.env.KEYSTREAM_CACHE_MAX) || 100;
|
||||
const keystreamCache = new Map();
|
||||
|
||||
/**
|
||||
* 页面池管理器 - 支持并发处理
|
||||
*/
|
||||
@@ -349,6 +356,51 @@ async function evaluateWithTimeout(pageFunction, arg, timeout = 30000) {
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== 密钥流缓存 ====================
|
||||
|
||||
/**
|
||||
* 读取密钥流缓存(命中后移到最近使用位置)
|
||||
* @param {string|number} decodeKey
|
||||
* @returns {Buffer|null}
|
||||
*/
|
||||
function getCachedKeystream(decodeKey) {
|
||||
const key = String(decodeKey);
|
||||
const buf = keystreamCache.get(key);
|
||||
if (!buf) return null;
|
||||
keystreamCache.delete(key);
|
||||
keystreamCache.set(key, buf); // 重新插入到队尾,标记为最近使用
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入密钥流缓存,超出上限时淘汰最久未使用项
|
||||
* @param {string|number} decodeKey
|
||||
* @param {Buffer} buffer
|
||||
*/
|
||||
function setCachedKeystream(decodeKey, buffer) {
|
||||
keystreamCache.set(String(decodeKey), buffer);
|
||||
if (keystreamCache.size > KEYSTREAM_CACHE_MAX) {
|
||||
const oldest = keystreamCache.keys().next().value;
|
||||
keystreamCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析密钥流:优先命中缓存,否则调用浏览器生成并写入缓存
|
||||
* @param {string|number} decodeKey
|
||||
* @param {() => Promise<string>} generate 返回 base64 密钥流的生成函数(浏览器侧)
|
||||
* @returns {Promise<Buffer>} 128KB 密钥流 Buffer
|
||||
*/
|
||||
async function resolveKeystream(decodeKey, generate) {
|
||||
const cached = getCachedKeystream(decodeKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const keystreamBase64 = await generate();
|
||||
const keystream = Buffer.from(keystreamBase64, 'base64');
|
||||
setCachedKeystream(decodeKey, keystream);
|
||||
return keystream;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Playwright 浏览器
|
||||
*/
|
||||
@@ -412,7 +464,7 @@ app.get('/health', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'wechat-decrypt-api',
|
||||
version: '2.0.0',
|
||||
version: '2.1.0',
|
||||
engine: 'playwright',
|
||||
wasm: wasmStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -448,7 +500,7 @@ app.get('/worker.html', (req, res) => {
|
||||
app.get('/api/info', (req, res) => {
|
||||
res.json({
|
||||
service: 'WeChat Channels Video Decryption API',
|
||||
version: '2.0.0',
|
||||
version: '2.1.0',
|
||||
engine: 'Playwright + Chromium',
|
||||
author: 'Evil0ctal',
|
||||
github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption',
|
||||
@@ -513,7 +565,7 @@ app.post('/api/keystream', async (req, res) => {
|
||||
decode_key,
|
||||
keystream,
|
||||
format,
|
||||
size: 131072,
|
||||
size: KEYSTREAM_SIZE,
|
||||
duration_ms: duration,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -548,48 +600,40 @@ app.post('/api/decrypt', upload.single('video'), async (req, res) => {
|
||||
console.log(` decode_key: ${decode_key}`);
|
||||
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
|
||||
// 根据文件大小动态设置超时时间(基础30秒 + 每MB额外1秒)
|
||||
const fileSizeMB = videoFile.size / 1024 / 1024;
|
||||
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
|
||||
const startTime = Date.now();
|
||||
|
||||
// 使用锁机制确保串行处理
|
||||
const result = await withPageLock(async () => {
|
||||
const startTime = Date.now();
|
||||
// 密钥流命中缓存时无需浏览器,直接在 Node.js 端解密
|
||||
const cached = getCachedKeystream(decode_key);
|
||||
let result;
|
||||
if (cached) {
|
||||
console.log(' [1/2] 命中密钥流缓存');
|
||||
console.log(' [2/2] 执行 XOR 解密...');
|
||||
const decrypted = decryptBuffer(videoFile.buffer, cached);
|
||||
assertMp4(decrypted);
|
||||
result = { decrypted, duration: Date.now() - startTime };
|
||||
} else {
|
||||
// 未命中:用锁机制串行调用浏览器生成密钥流,再在 Node.js 端 XOR
|
||||
result = await withPageLock(async () => {
|
||||
// 步骤 1: 生成密钥流(浏览器 WASM,仅 128KB,不经 CDP 传大文件)
|
||||
console.log(' [1/2] 生成密钥流...');
|
||||
const keystream = await resolveKeystream(decode_key, () =>
|
||||
evaluateWithTimeout(
|
||||
async (key) => await window.generateKeystream(key),
|
||||
decode_key,
|
||||
30000
|
||||
)
|
||||
);
|
||||
|
||||
// 步骤 1: 生成密钥流
|
||||
console.log(' [1/3] 生成密钥流...');
|
||||
const keystreamBase64 = await evaluateWithTimeout(
|
||||
async (key) => await window.generateKeystream(key),
|
||||
decode_key,
|
||||
30000
|
||||
);
|
||||
// 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
|
||||
console.log(' [2/2] 执行 XOR 解密...');
|
||||
const decrypted = decryptBuffer(videoFile.buffer, keystream);
|
||||
assertMp4(decrypted);
|
||||
|
||||
// 步骤 2: 转换视频为 Base64
|
||||
console.log(' [2/3] 编码视频数据...');
|
||||
const encryptedBase64 = videoFile.buffer.toString('base64');
|
||||
return { decrypted, duration: Date.now() - startTime };
|
||||
});
|
||||
}
|
||||
|
||||
// 步骤 3: 在浏览器中执行解密(大文件需要更长超时)
|
||||
console.log(' [3/3] 执行 XOR 解密...');
|
||||
const decryptedBase64 = await evaluateWithTimeout(
|
||||
(params) => window.decryptVideo(params.encrypted, params.keystream),
|
||||
{ encrypted: encryptedBase64, keystream: keystreamBase64 },
|
||||
timeout
|
||||
);
|
||||
|
||||
// Base64 → Buffer
|
||||
const decrypted = Buffer.from(decryptedBase64, 'base64');
|
||||
|
||||
// 验证 MP4 签名
|
||||
const ftyp = decrypted.toString('utf8', 4, 8);
|
||||
if (ftyp !== 'ftyp') {
|
||||
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`✅ 解密成功,耗时 ${duration}ms`);
|
||||
|
||||
return { decrypted, duration };
|
||||
});
|
||||
console.log(`✅ 解密成功,耗时 ${result.duration}ms`);
|
||||
|
||||
// 返回解密后的视频
|
||||
res.set({
|
||||
@@ -633,50 +677,40 @@ app.post('/api/decrypt-concurrent', upload.single('video'), async (req, res) =>
|
||||
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
console.log(` 页面池状态: ${JSON.stringify(pagePool.getStatus())}`);
|
||||
|
||||
// 根据文件大小动态设置超时时间
|
||||
const fileSizeMB = videoFile.size / 1024 / 1024;
|
||||
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
|
||||
const startTime = Date.now();
|
||||
|
||||
// 使用页面池执行(支持并发)
|
||||
const result = await withPoolPage(async (pg) => {
|
||||
const startTime = Date.now();
|
||||
// 密钥流命中缓存时无需占用页面,直接在 Node.js 端解密
|
||||
const cached = getCachedKeystream(decode_key);
|
||||
let result;
|
||||
if (cached) {
|
||||
console.log(' [并发] 命中密钥流缓存,跳过页面池');
|
||||
const decrypted = decryptBuffer(videoFile.buffer, cached);
|
||||
assertMp4(decrypted);
|
||||
result = { decrypted, duration: Date.now() - startTime };
|
||||
} else {
|
||||
// 未命中:用页面池生成密钥流(支持并发),再在 Node.js 端 XOR
|
||||
result = await withPoolPage(async (pg) => {
|
||||
// 步骤 1: 生成密钥流(浏览器 WASM,仅 128KB)
|
||||
console.log(` [页面#${pg._poolIndex}] [1/2] 生成密钥流...`);
|
||||
const keystream = await resolveKeystream(decode_key, () =>
|
||||
evaluateWithTimeoutOnPage(
|
||||
pg,
|
||||
async (key) => await window.generateKeystream(key),
|
||||
decode_key,
|
||||
30000
|
||||
)
|
||||
);
|
||||
|
||||
// 步骤 1: 生成密钥流
|
||||
console.log(` [页面#${pg._poolIndex}] [1/3] 生成密钥流...`);
|
||||
const keystreamBase64 = await evaluateWithTimeoutOnPage(
|
||||
pg,
|
||||
async (key) => await window.generateKeystream(key),
|
||||
decode_key,
|
||||
30000
|
||||
);
|
||||
// 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
|
||||
console.log(` [页面#${pg._poolIndex}] [2/2] 执行 XOR 解密...`);
|
||||
const decrypted = decryptBuffer(videoFile.buffer, keystream);
|
||||
assertMp4(decrypted);
|
||||
|
||||
// 步骤 2: 转换视频为 Base64
|
||||
console.log(` [页面#${pg._poolIndex}] [2/3] 编码视频数据...`);
|
||||
const encryptedBase64 = videoFile.buffer.toString('base64');
|
||||
return { decrypted, duration: Date.now() - startTime };
|
||||
});
|
||||
}
|
||||
|
||||
// 步骤 3: 在浏览器中执行解密
|
||||
console.log(` [页面#${pg._poolIndex}] [3/3] 执行 XOR 解密...`);
|
||||
const decryptedBase64 = await evaluateWithTimeoutOnPage(
|
||||
pg,
|
||||
(params) => window.decryptVideo(params.encrypted, params.keystream),
|
||||
{ encrypted: encryptedBase64, keystream: keystreamBase64 },
|
||||
timeout
|
||||
);
|
||||
|
||||
// Base64 → Buffer
|
||||
const decrypted = Buffer.from(decryptedBase64, 'base64');
|
||||
|
||||
// 验证 MP4 签名
|
||||
const ftyp = decrypted.toString('utf8', 4, 8);
|
||||
if (ftyp !== 'ftyp') {
|
||||
throw new Error('解密失败:未找到 MP4 ftyp 签名,请检查 decode_key');
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`✅ [页面#${pg._poolIndex}] 解密成功,耗时 ${duration}ms`);
|
||||
|
||||
return { decrypted, duration };
|
||||
});
|
||||
console.log(`✅ [并发] 解密成功,耗时 ${result.duration}ms`);
|
||||
|
||||
// 返回解密后的视频
|
||||
res.set({
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* lib/decrypt 单元测试(零依赖,node test/decrypt.test.js 运行)
|
||||
*
|
||||
* 验证 decryptBuffer 与参考实现(Python CLI decrypt_video / 旧 worker.html
|
||||
* decryptVideo)的 XOR 逻辑字节级一致,覆盖大文件边界场景。
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { KEYSTREAM_SIZE, decryptBuffer, assertMp4 } = require('../lib/decrypt');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考实现:等价于 Python CLI 的
|
||||
* decrypt_len = min(len(keystream), len(encrypted))
|
||||
* 前 decrypt_len 字节做 XOR,其余原样
|
||||
*/
|
||||
function referenceDecrypt(encrypted, keystream) {
|
||||
const out = Buffer.alloc(encrypted.length);
|
||||
const n = Math.min(keystream.length, encrypted.length);
|
||||
for (let i = 0; i < n; i++) out[i] = encrypted[i] ^ keystream[i];
|
||||
for (let i = n; i < encrypted.length; i++) out[i] = encrypted[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// 用固定种子的伪随机生成可复现数据(避免依赖 Math.random)
|
||||
function pseudoBuffer(size, seed) {
|
||||
const buf = Buffer.alloc(size);
|
||||
let s = seed >>> 0;
|
||||
for (let i = 0; i < size; i++) {
|
||||
s = (s * 1664525 + 1013904223) >>> 0; // LCG
|
||||
buf[i] = s & 0xff;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
console.log('运行 lib/decrypt 测试...\n');
|
||||
|
||||
const keystream = pseudoBuffer(KEYSTREAM_SIZE, 12345);
|
||||
|
||||
test('小文件(<128KB):仅前段加密,结果与参考实现一致', () => {
|
||||
const encrypted = pseudoBuffer(50 * 1024, 7);
|
||||
const got = decryptBuffer(encrypted, keystream);
|
||||
assert.deepStrictEqual(got, referenceDecrypt(encrypted, keystream));
|
||||
});
|
||||
|
||||
test('恰好 128KB:全量加密,结果与参考实现一致', () => {
|
||||
const encrypted = pseudoBuffer(KEYSTREAM_SIZE, 99);
|
||||
const got = decryptBuffer(encrypted, keystream);
|
||||
assert.deepStrictEqual(got, referenceDecrypt(encrypted, keystream));
|
||||
});
|
||||
|
||||
test('大文件(120MB > CDP 100MB 上限):前 128KB 加密、其余明文保留', () => {
|
||||
const size = 120 * 1024 * 1024;
|
||||
const encrypted = pseudoBuffer(size, 2026);
|
||||
const got = decryptBuffer(encrypted, keystream);
|
||||
|
||||
// 前 128KB 应被 XOR
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
assert.strictEqual(got[i], encrypted[i] ^ keystream[i]);
|
||||
}
|
||||
// 128KB 之后应为明文(与原始一致)
|
||||
assert.strictEqual(got[KEYSTREAM_SIZE], encrypted[KEYSTREAM_SIZE]);
|
||||
assert.strictEqual(got[size - 1], encrypted[size - 1]);
|
||||
assert.strictEqual(got.length, size);
|
||||
});
|
||||
|
||||
test('XOR 可逆:decrypt(encrypt(x)) === x', () => {
|
||||
const plain = pseudoBuffer(200 * 1024, 555);
|
||||
const encrypted = decryptBuffer(plain, keystream); // XOR 即加密
|
||||
const back = decryptBuffer(encrypted, keystream); // 再 XOR 即解密
|
||||
assert.deepStrictEqual(back, plain);
|
||||
});
|
||||
|
||||
test('decryptBuffer 不修改入参 encrypted', () => {
|
||||
const encrypted = pseudoBuffer(10 * 1024, 1);
|
||||
const copy = Buffer.from(encrypted);
|
||||
decryptBuffer(encrypted, keystream);
|
||||
assert.deepStrictEqual(encrypted, copy);
|
||||
});
|
||||
|
||||
test('assertMp4:合法 ftyp 通过', () => {
|
||||
const buf = Buffer.concat([Buffer.from([0, 0, 0, 0]), Buffer.from('ftypisom')]);
|
||||
assert.doesNotThrow(() => assertMp4(buf));
|
||||
});
|
||||
|
||||
test('assertMp4:缺失签名抛错', () => {
|
||||
const buf = Buffer.from('not a valid mp4 header here');
|
||||
assert.throws(() => assertMp4(buf), /ftyp/);
|
||||
});
|
||||
|
||||
console.log(`\n✅ 全部 ${passed} 项测试通过`);
|
||||
@@ -122,6 +122,12 @@
|
||||
|
||||
/**
|
||||
* RPC 方法:解密视频数据
|
||||
*
|
||||
* @deprecated 自 v2.1 起服务端不再调用此方法。XOR 解密已移至 Node.js 端
|
||||
* (server.js 的 decryptBuffer),避免把整段视频经 CDP 管道传入浏览器而触发
|
||||
* DevTools 协议 100MB 单条消息上限(导致大文件解密时页面崩溃)。
|
||||
* 此处仅为向后兼容保留,浏览器只负责通过 generateKeystream 生成密钥流。
|
||||
*
|
||||
* @param {string} encryptedBase64 - Base64 编码的加密视频数据
|
||||
* @param {string} keystreamBase64 - Base64 编码的密钥流
|
||||
* @returns {string} Base64 编码的解密后数据
|
||||
|
||||
Reference in new issue
Block a user