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:
Evil0ctal committed 2026-06-09 22:33:15 -07:00
1 parent 9f0c8a5def
commit 8900a7c6fb
8 files changed
+366 -102

No files matched your search

+5
View File
@@ -205,3 +205,8 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
/todo/
# api-service 为 Node.js 子项目,其源码目录不应被 Python 模板的 lib/ 规则忽略
!api-service/lib/
!api-service/lib/**
+1
View File
@@ -63,6 +63,7 @@ COPY --from=builder /root/.cache/ms-playwright /ms-playwright
COPY server.js . COPY server.js .
COPY worker.html . COPY worker.html .
COPY docs.html . COPY docs.html .
COPY lib ./lib
COPY wechat_files ./wechat_files COPY wechat_files ./wechat_files
# Create non-root user for security # Create non-root user for security
+98 -19
View File
@@ -15,6 +15,37 @@
- **健康检查**: 内置服务健康监控 - **健康检查**: 内置服务健康监控
- **大文件支持**: 最大支持 500MB 视频文件 - **大文件支持**: 最大支持 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 │ │ Express.js API Server │
│ (Node.js + Multer + CORS) │ │ (Node.js + Multer + CORS) │
└───────────────────┬─────────────────────────────────┘ └───────────────────┬─────────────────────────────────┘
│ RPC Call via page.evaluate() │ ① RPC:仅生成 128KB 密钥流
│ page.evaluate(generateKeystream)
▼ ▼
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ Playwright Chromium Browser │ │ Playwright Chromium Browser │
@@ -39,13 +71,24 @@
│ │ └─────────────────────────────────┘ │ │ │ │ └─────────────────────────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ RPC Functions: │ │ │ │ RPC Functions: │ │
│ │ - generateKeystream(decodeKey) │ │ │ │ - generateKeystream(decodeKey) ← 唯一职责 │ │
│ │ - decryptVideo(encrypted, keystream) │ │
│ │ - checkWasmStatus() │ │ │ │ - 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? ### 为什么选择 Playwright?
WeChat 的 WASM 模块依赖浏览器特定的 API (`fetch`, `self`, `window` 等),无法直接在 Node.js 环境中运行。Playwright 方案的优势: WeChat 的 WASM 模块依赖浏览器特定的 API (`fetch`, `self`, `window` 等),无法直接在 Node.js 环境中运行。Playwright 方案的优势:
@@ -129,7 +172,7 @@ GET /
```json ```json
{ {
"service": "WeChat Channels Video Decryption API", "service": "WeChat Channels Video Decryption API",
"version": "2.0.0", "version": "2.1.0",
"engine": "Playwright + Chromium", "engine": "Playwright + Chromium",
"author": "Evil0ctal", "author": "Evil0ctal",
"endpoints": { "endpoints": {
@@ -153,7 +196,7 @@ GET /health
{ {
"status": "ok", "status": "ok",
"service": "wechat-decrypt-api", "service": "wechat-decrypt-api",
"version": "2.0.0", "version": "2.1.0",
"engine": "playwright", "engine": "playwright",
"wasm": { "wasm": {
"loaded": true, "loaded": true,
@@ -428,19 +471,22 @@ const keystreamBase64 = await page.evaluate(async (key) => {
**关键**: 使用 `http://` 而非 `file://` 协议,避免浏览器 CORS 限制,使本地 WASM 文件加载成功。 **关键**: 使用 `http://` 而非 `file://` 协议,避免浏览器 CORS 限制,使本地 WASM 文件加载成功。
### 3. 数据传输 ### 3. 数据传输(v2.1:视频不进浏览器)
使用 Base64 编码在 Node.js 和浏览器之间传输二进制数据: 浏览器与 Node.js 之间**只传输 128KB 密钥流**,加密视频本身始终留在 Node.js 端:
```javascript ```javascript
// Node.js → Browser // Browser → Node.js:仅密钥流(base64,约 175KB,远小于 CDP 100MB 上限)
const encryptedBase64 = videoFile.buffer.toString('base64'); const keystreamBase64 = await page.evaluate(
async (key) => await window.generateKeystream(key),
// Browser → Node.js decode_key
const decryptedBase64 = await page.evaluate(...); );
const decrypted = Buffer.from(decryptedBase64, 'base64'); const keystream = Buffer.from(keystreamBase64, 'base64');
``` ```
> 旧版会把整段视频 `toString('base64')` 传入浏览器,80MB 文件膨胀到约
> 107MB,超过 CDP 管道 100MB 上限而导致页面崩溃。v2.1 不再传输视频数据。
### 4. Isaac64 密钥流生成 ### 4. Isaac64 密钥流生成
WASM 模块实现了微信魔改的 Isaac64 算法: WASM 模块实现了微信魔改的 Isaac64 算法:
@@ -455,16 +501,25 @@ window.wasm_isaac_generate = function(ptr, size) {
**重要**: 密钥流必须反转 (`reverse()`) 才能正确解密,这是微信特有的实现细节。 **重要**: 密钥流必须反转 (`reverse()`) 才能正确解密,这是微信特有的实现细节。
### 5. XOR 解密 ### 5. XOR 解密(v2.1:Node.js 端执行)
前 128KB 数据通过 XOR 操作解密: 微信仅加密文件**前 128KB**,其余为明文。XOR 解密在 Node.js 端用 Buffer 完成
(`lib/decrypt.js` 的 `decryptBuffer`),无需把视频送进浏览器:
```javascript ```javascript
for (let i = 0; i < 131072 && i < encrypted.length; i++) { function decryptBuffer(encrypted, keystream) {
decrypted[i] = encrypted[i] ^ keystream[i]; 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 模块加载超时 ### 问题: WASM 模块加载超时
@@ -506,6 +561,20 @@ docker-compose build --no-cache
2. 检查 API 响应中的错误信息 2. 检查 API 响应中的错误信息
3. 验证解密文件的前 12 字节应为: `00 00 00 XX 66 74 79 70` (MP4 签名) 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 错误) ### 问题: 文件上传失败 (413 错误)
**症状**: **症状**:
@@ -537,9 +606,11 @@ deploy:
memory: 4G # 增加内存限制 memory: 4G # 增加内存限制
``` ```
### 3. 启用请求缓存 ### 3. 密钥流缓存(v2.1 已内置)
对于相同的 decode_key,可以缓存生成的密钥流以提高性能。 相同 `decode_key` 的密钥流恒定,服务端已内置 LRU 缓存:命中后跳过浏览器 WASM
调用(并发接口连页面池都不占用)。缓存上限可通过环境变量 `KEYSTREAM_CACHE_MAX`
配置(默认 100)。
### 4. 负载均衡 ### 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) ### v2.0.0 (2025-10-17)
- 采用 Playwright + RPC 架构 - 采用 Playwright + RPC 架构
- 100% 兼容微信官方 WASM v1.2.46 - 100% 兼容微信官方 WASM v1.2.46
+42
View File
@@ -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 };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "wechat-decrypt-api-playwright", "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", "description": "WeChat Channels Video Decryption API Service (Playwright Edition) - 100% compatible with WeChat official WASM module",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "node test.js", "test": "node test/decrypt.test.js",
"install-browsers": "npx playwright install chromium --with-deps" "install-browsers": "npx playwright install chromium --with-deps"
}, },
"keywords": [ "keywords": [
+115 -81
View File
@@ -13,6 +13,7 @@ const { chromium } = require('playwright');
const multer = require('multer'); const multer = require('multer');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const { KEYSTREAM_SIZE, decryptBuffer, assertMp4 } = require('./lib/decrypt');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; 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个并发页面 const POOL_SIZE = parseInt(process.env.POOL_SIZE) || 3; // 默认3个并发页面
let pagePool = null; 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 浏览器 * 初始化 Playwright 浏览器
*/ */
@@ -412,7 +464,7 @@ app.get('/health', async (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
service: 'wechat-decrypt-api', service: 'wechat-decrypt-api',
version: '2.0.0', version: '2.1.0',
engine: 'playwright', engine: 'playwright',
wasm: wasmStatus, wasm: wasmStatus,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
@@ -448,7 +500,7 @@ app.get('/worker.html', (req, res) => {
app.get('/api/info', (req, res) => { app.get('/api/info', (req, res) => {
res.json({ res.json({
service: 'WeChat Channels Video Decryption API', service: 'WeChat Channels Video Decryption API',
version: '2.0.0', version: '2.1.0',
engine: 'Playwright + Chromium', engine: 'Playwright + Chromium',
author: 'Evil0ctal', author: 'Evil0ctal',
github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption', github: 'https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption',
@@ -513,7 +565,7 @@ app.post('/api/keystream', async (req, res) => {
decode_key, decode_key,
keystream, keystream,
format, format,
size: 131072, size: KEYSTREAM_SIZE,
duration_ms: duration, duration_ms: duration,
timestamp: new Date().toISOString() 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(` decode_key: ${decode_key}`);
console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`); console.log(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
// 根据文件大小动态设置超时时间(基础30秒 + 每MB额外1秒) const startTime = Date.now();
const fileSizeMB = videoFile.size / 1024 / 1024;
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
// 使用锁机制确保串行处理 // 密钥流命中缓存时无需浏览器,直接在 Node.js 端解密
const result = await withPageLock(async () => { const cached = getCachedKeystream(decode_key);
const startTime = Date.now(); 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: 生成密钥流 // 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
console.log(' [1/3] 生成密钥流...'); console.log(' [2/2] 执行 XOR 解密...');
const keystreamBase64 = await evaluateWithTimeout( const decrypted = decryptBuffer(videoFile.buffer, keystream);
async (key) => await window.generateKeystream(key), assertMp4(decrypted);
decode_key,
30000
);
// 步骤 2: 转换视频为 Base64 return { decrypted, duration: Date.now() - startTime };
console.log(' [2/3] 编码视频数据...'); });
const encryptedBase64 = videoFile.buffer.toString('base64'); }
// 步骤 3: 在浏览器中执行解密(大文件需要更长超时) console.log(`✅ 解密成功,耗时 ${result.duration}ms`);
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 };
});
// 返回解密后的视频 // 返回解密后的视频
res.set({ 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(` 文件: ${videoFile.originalname} (${(videoFile.size / 1024 / 1024).toFixed(2)} MB)`);
console.log(` 页面池状态: ${JSON.stringify(pagePool.getStatus())}`); console.log(` 页面池状态: ${JSON.stringify(pagePool.getStatus())}`);
// 根据文件大小动态设置超时时间 const startTime = Date.now();
const fileSizeMB = videoFile.size / 1024 / 1024;
const timeout = Math.max(60000, 30000 + fileSizeMB * 1000);
// 使用页面池执行(支持并发) // 密钥流命中缓存时无需占用页面,直接在 Node.js 端解密
const result = await withPoolPage(async (pg) => { const cached = getCachedKeystream(decode_key);
const startTime = Date.now(); 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: 生成密钥流 // 步骤 2: 在 Node.js 端执行 XOR 解密(规避 CDP 100MB 限制)
console.log(` [页面#${pg._poolIndex}] [1/3] 生成密钥流...`); console.log(` [页面#${pg._poolIndex}] [2/2] 执行 XOR 解密...`);
const keystreamBase64 = await evaluateWithTimeoutOnPage( const decrypted = decryptBuffer(videoFile.buffer, keystream);
pg, assertMp4(decrypted);
async (key) => await window.generateKeystream(key),
decode_key,
30000
);
// 步骤 2: 转换视频为 Base64 return { decrypted, duration: Date.now() - startTime };
console.log(` [页面#${pg._poolIndex}] [2/3] 编码视频数据...`); });
const encryptedBase64 = videoFile.buffer.toString('base64'); }
// 步骤 3: 在浏览器中执行解密 console.log(`✅ [并发] 解密成功,耗时 ${result.duration}ms`);
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 };
});
// 返回解密后的视频 // 返回解密后的视频
res.set({ res.set({
+97
View File
@@ -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} 项测试通过`);
+6
View File
@@ -122,6 +122,12 @@
/** /**
* RPC 方法:解密视频数据 * RPC 方法:解密视频数据
*
* @deprecated 自 v2.1 起服务端不再调用此方法。XOR 解密已移至 Node.js 端
* (server.js 的 decryptBuffer),避免把整段视频经 CDP 管道传入浏览器而触发
* DevTools 协议 100MB 单条消息上限(导致大文件解密时页面崩溃)。
* 此处仅为向后兼容保留,浏览器只负责通过 generateKeystream 生成密钥流。
*
* @param {string} encryptedBase64 - Base64 编码的加密视频数据 * @param {string} encryptedBase64 - Base64 编码的加密视频数据
* @param {string} keystreamBase64 - Base64 编码的密钥流 * @param {string} keystreamBase64 - Base64 编码的密钥流
* @returns {string} Base64 编码的解密后数据 * @returns {string} Base64 编码的解密后数据