diff --git a/.gitignore b/.gitignore index b7faf40..664248c 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,8 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ +/todo/ + +# api-service 为 Node.js 子项目,其源码目录不应被 Python 模板的 lib/ 规则忽略 +!api-service/lib/ +!api-service/lib/** diff --git a/api-service/Dockerfile b/api-service/Dockerfile index 6506489..3f1914c 100644 --- a/api-service/Dockerfile +++ b/api-service/Dockerfile @@ -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 diff --git a/api-service/README.md b/api-service/README.md index 4ce73f0..11c15e0 100644 --- a/api-service/README.md +++ b/api-service/README.md @@ -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 diff --git a/api-service/lib/decrypt.js b/api-service/lib/decrypt.js new file mode 100644 index 0000000..0468008 --- /dev/null +++ b/api-service/lib/decrypt.js @@ -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 }; diff --git a/api-service/package.json b/api-service/package.json index d1252ab..c0b8433 100644 --- a/api-service/package.json +++ b/api-service/package.json @@ -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": [ diff --git a/api-service/server.js b/api-service/server.js index 4f198b3..fb64416 100644 --- a/api-service/server.js +++ b/api-service/server.js @@ -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} generate 返回 base64 密钥流的生成函数(浏览器侧) + * @returns {Promise} 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({ diff --git a/api-service/test/decrypt.test.js b/api-service/test/decrypt.test.js new file mode 100644 index 0000000..c9b8797 --- /dev/null +++ b/api-service/test/decrypt.test.js @@ -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} 项测试通过`); diff --git a/api-service/worker.html b/api-service/worker.html index 4abb12d..da86dcb 100644 --- a/api-service/worker.html +++ b/api-service/worker.html @@ -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 编码的解密后数据