如何在Web端用JavaScript分块解密1GB以上的AES-CBC加密大文件?
浏览器端大文件AES-CBC流式解密的原生API实现思路
我们尝试在浏览器端用JavaScript实现大文件流式解密,由于crypto.subtle原生不支持流式解密,因此采用分块解密方案——理论上可将前一块密文作为当前块的初始化向量(IV)。但发现crypto.subtle的AES-CBC解密始终要求密文末尾带有PKCS#7填充,这让实现难度大增。请问有没有基于原生API的JavaScript实现示例或思路?
当前实现代码如下:
const padding = new Uint8Array(16).fill(16); const chunkSize = 16; let index = 0; const chunks = []; let prevChunk = null; do { const chunk = ciphertext.slice(index, index + chunkSize); // Encrypt padding with the chunk as iv const paddingCypher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: chunk }, key, padding); const encryptedPadding = (new Uint8Array(paddingCypher)).slice(0, 16); const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: prevChunk || subtleIv}, subtleKey, mergeByteArrays([chunk, encryptedPadding])); chunks.push(new Uint8Array(decrypted)); prevChunk = chunk; index += chunkSize; } while (index < length - chunkSize); // Different for last chunk as that already has the padding const lastChunk = await decrypt( prevChunk || subtleIv, subtleKey, ciphertext.slice(index, index + chunkSize) ); chunks.push(new Uint8Array(lastChunk)); var mergedChunks = mergeByteArrays(chunks); // Decode and output var fullyDecrypted = String.fromCharCode.apply(null, mergedChunks as any); console.log(fullyDecrypted);
核心思路与修正实现
由于crypto.subtle的AES-CBC解密必须处理带PKCS#7填充的完整密文,直接分块解密单个无填充块会失败。我们可以利用CBC模式的链式特性,结合原生API实现流式解密,具体步骤如下:
- 按16字节整数倍分块(如16KB),最后一块单独处理(自带填充)
- 前N-1块通过拼接虚拟填充密文块,构造符合API要求的密文结构,解密后提取真实明文
- 最后一块直接解密后去除PKCS#7填充
修正后的实现代码:
/** * 合并Uint8Array数组 * @param {Uint8Array[]} arrays 要合并的数组列表 * @returns {Uint8Array} 合并后的数组 */ function mergeByteArrays(arrays) { let totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); let result = new Uint8Array(totalLength); let offset = 0; for (let arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } /** * 去除PKCS#7填充 * @param {Uint8Array} data 带填充的数据 * @returns {Uint8Array} 去除填充后的原始数据 */ function removePkcs7Padding(data) { const paddingLength = data[data.length - 1]; if (paddingLength < 1 || paddingLength > 16) { throw new Error('Invalid PKCS#7 padding'); } return data.slice(0, data.length - paddingLength); } /** * 流式解密AES-CBC密文 * @param {CryptoKey} key AES密钥 * @param {Uint8Array} iv 初始IV * @param {Uint8Array} ciphertext 完整密文 * @param {number} chunkSize 分块大小(必须是16的整数倍) * @returns {Promise<Uint8Array>} 解密后的明文 */ async function streamDecryptAesCbc(key, iv, ciphertext, chunkSize = 16 * 1024) { if (chunkSize % 16 !== 0) { throw new Error('Chunk size must be a multiple of 16'); } const chunks = []; let currentIv = iv; const totalLength = ciphertext.length; let index = 0; // 处理前N-1块(无填充) while (index + chunkSize < totalLength) { const chunk = ciphertext.slice(index, index + chunkSize); // 生成虚拟填充对应的密文块 const dummyPadding = new Uint8Array(16).fill(16); const dummyCipherBlock = await crypto.subtle.encrypt( { name: 'AES-CBC', iv: chunk }, key, dummyPadding ); const dummyCipherBytes = new Uint8Array(dummyCipherBlock); // 拼接当前块与虚拟密文块,构造可解密的完整密文 const combinedCipher = mergeByteArrays([chunk, dummyCipherBytes]); const decryptedCombined = await crypto.subtle.decrypt( { name: 'AES-CBC', iv: currentIv }, key, combinedCipher ); const decryptedBytes = new Uint8Array(decryptedCombined); // 提取真实明文(去掉最后16字节虚拟填充) chunks.push(decryptedBytes.slice(0, chunkSize)); currentIv = chunk; index += chunkSize; } // 处理最后一块(带PKCS#7填充) const lastChunk = ciphertext.slice(index); const decryptedLast = await crypto.subtle.decrypt( { name: 'AES-CBC', iv: currentIv }, key, lastChunk ); chunks.push(removePkcs7Padding(new Uint8Array(decryptedLast))); return mergeByteArrays(chunks); } // 使用示例 async function exampleUsage() { // 假设已获取subtleKey、subtleIv、ciphertext const plaintext = await streamDecryptAesCbc(subtleKey, subtleIv, ciphertext); const fullyDecrypted = new TextDecoder().decode(plaintext); console.log(fullyDecrypted); }
关键说明
- 虚拟填充块:通过加密标准PKCS#7填充块生成对应密文,拼接后满足原生API对密文必须带填充的要求,解密后再剔除虚拟填充部分
- IV传递:严格遵循CBC规则,每块密文解密完成后,将当前密文块作为下一块的IV
- 填充校验:最后一块解密后手动校验并去除PKCS#7填充,避免多余数据残留
内容的提问来源于stack exchange,提问作者Stef
相关产品推荐
相关产品推荐

