前端加密实战:基于Web Crypto API的RSA+AES混合加密与大文件分块方案
做前端加密这件事最容易踩的坑不是“不会调 API”而是把整个流程想得太简单以为拿到公钥加密一下就算完事结果要么大文件直接把浏览器内存撑爆要么密文结构设计得一塌糊涂后面版本迭代想加字段都没法兼容。最近我把某跨平台系统里的“Send”文件分享场景完整梳理了一遍从 RSA 密钥对生成、AES 会话密钥创建到最终把大文件分块加密落盘把整条链路做成了可以直接复用的前端加密流程。这篇内容适合正在做文件安全分享、云盘加密、隐私数据保护相关功能的前端同学也适合想搞清楚 Web Crypto API 到底怎么落地的人。这里说的“前端加密”不是简单在 JS 里把字符串倒一倒而是要把“信任边界”从服务器搬到客户端明文只在用户浏览器里出现服务端只负责存和传密文。所以整个方案的核心是端到端加密服务端那台机器就算被拖库攻击者拿到的也只是一堆无法解密的二进制。要达到这个效果密钥体系、分块策略、密文格式、异常兜底都得在动手之前想清楚。1. 整体方案设计前端加密不是“把密码藏起来”1.1 为什么必须用混合加密非对称慢、对称快、两者互补刚开始接触加密的同学经常会有个疑问既然 AES 能加密为什么还要用 RSA反过来想既然 RSA 更安全为什么不全用 RSA实际上这两种算法各有各的适用场景。RSA 属于非对称加密公钥可以随便分发私钥留在本地非常适合解决“密钥怎么安全传给对方”的问题。但它的性能太差了2048 位 RSA-OAEP 每秒钟最多也就加密几十 KB 到上百 KB 数据拿它去加密一个几百 MB 的文件用户体验会非常糟糕。AES 这类对称加密则快得多硬件级加速下加解密每秒能跑几百 MB 甚至 GB 级但它的致命弱点是加密和解密必须用同一个密钥这个密钥怎么安全地交给对方于是就有了混合加密方案用 AES 生成一个一次性的会话密钥去加密文件本体再用 RSA 公钥去加密这个会话密钥。对方收到后先用 RSA 私钥解开拿到会话密钥再用会话密钥去解密文件。RSA 只处理几十字节的密钥数据性能压力几乎可以忽略而大文件全部交给 AES 处理速度和安全性两头都占住了。记住一个判断标准数据量大用对称加密密钥分发用非对称加密。两者组合不是炫技而是工程上最优的取舍。为什么 RSA-OAEP 适合包裹会话密钥OAEPOptimal Asymmetric Encryption Padding是 RSA 的一种填充方式它在明文前面加入随机字节让同样的明文每次加密结果都不同能有效抵抗选择明文攻击。2048 位 RSA-OAEP 配上 SHA-256最大能加密的明文长度大约只有 190 字节左右而 AES-256 的原始密钥恰好是 32 字节放进去绰绰有余。1.2 浏览器端能做加密吗Web Crypto API 的能力边界与安全前提浏览器里做加密首选就是 Web Crypto API。它是 W3C 标准由浏览器原生实现底层调用的往往是操作系统的加密库性能和安全性都远超任何纯 JS 轮子。你可以在crypto.subtle上调用生成密钥、加密、解密、摘要等操作。但这个 API 有一个硬性前提必须在安全上下文Secure Context中才能使用。什么叫安全上下文简单说就是 HTTPS 环境或者localhost/127.0.0.1本机调试环境。如果你的站点跑在纯 HTTP 上window.crypto.subtle会是undefined所有代码直接罢工。我在调试时踩过这个坑本地用局域网 IP 访问开发服务器结果整个页面在加密环节静默失败翻了大半天才发现是上下文不达标。另外要留意Web Crypto API 是异步的几乎所有操作都返回 Promise。这意味着你要用await去串联流程同时留意异常分支密钥生成失败、加密失败、环境不支持每一种都要有明确的用户提示不能一把try/catch吞掉然后假装没发生。还有一个能力边界必须提前说Web Crypto API 的subtle系列方法只能操作 ArrayBuffer 或 TypedArray不能直接吃File对象或Blob。所以文件读进来以后必须先转成二进制缓冲区再喂给加密函数。如果文件很大这个转换就不是一次读完的事需要分块处理后面第三章会详细讲。1.3 密文容器结构设计一套自描述的加密文件格式既然要做一个完整的加密流程输出就不能只是一段裸密文否则将来解密方不知道你用的是什么算法、IV 是什么、密钥被谁包裹过。标准做法是定义一套“自描述”的密文容器格式把算法参数、密钥材料、分块信息、校验信息全部写进一个结构体里。我在这套流程里用的容器结构是这样的字段说明用途version格式版本号如1后续升级算法时做兼容判断algorithm对称加密算法如AES-GCM解密方据此选择算法kdf密钥派生算法信息如果公钥体系缺失时走口令派生wrappedKey被 RSA 公钥加密后的会话密钥解密方用私钥解出会话密钥ivAES-GCM 的初始向量解密时作为参数传入chunkSize分块大小如 1MB解密时分块读取chunkCount总块数校验完整性防止块被增删aad附加验证数据描述绑定文件名、块索引、元数据data实际密文二进制真正的文件数据checksum解出明文后的 SHA-256 摘要验证解密结果是否完整这个容器在代码里可以理解成一个 JSON 头部加二进制数据体的拼接结构。头部放可读性强的 JSON 字符串密文二进制跟在后面。解析的时候先读取头部长度再根据长度切出数据区。设计成自描述格式还有一个好处将来加密算法升级比如从 AES-GCM 换成更先进的算法老的密文依然可以通过versionalgorithm走老逻辑解密不用强制用户一次性全部迁移。2. 密钥生成从零生成你的 RSA 密钥对与会话密钥2.1 生成 RSA 密钥对的完整代码与参数解释密钥是整个加密体系的基石。用 Web Crypto API 生成 RSA 密钥对很简单核心是crypto.subtle.generateKeyconst keyPair await crypto.subtle.generateKey( { name: RSA-OAEP, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: SHA-256 }, true, [encrypt, decrypt] );这里有几个参数值得多说两句。modulusLength指 RSA 模长一般选 2048 位。理论上 4096 更安全但生成速度更慢加密时 CPU 开销也更大在浏览器端会明显感觉卡顿。2048 在当前算力下足够安全也是工程上性能和安全的均衡点。publicExponent固定用65537十六进制0x010001这是业界标准选择不要自己去发明奇奇怪怪的指数否则安全性反而没法保证。hash: SHA-256指定 OAEP 填充时的哈希函数。以前很多老代码用 SHA-1但现代浏览器都支持 SHA-256直接用新的就好。最后两个参数非常重要extractable我传了true这意味着密钥对可以从浏览器里导出。如果传false密钥只能在内存里用任何方式都无法导出这也算一种安全保护但如果你需要让用户备份私钥就必须设为true。keyUsages数组里声明了这把密钥的用途公钥只做encrypt私钥只做decrypt浏览器会强制检查你在调用时如果用途不匹配直接抛异常。没有业务需要时密钥用途越窄越好。只声明你需要的那几个能力能有效减少误用导致的安全问题。在实际项目里公钥通常要发给对方所以需要导出成可传输的格式。导出公钥有两个常见格式spkiSubject Public Key Info和raw。raw对 RSA 来说只是裸的公钥字节不带算法标识不够自描述spki更标准兼容性更好推荐导出spki传输时再转成 Base64。const exportedPublicKey await crypto.subtle.exportKey( spki, keyPair.publicKey ); // 转成 Base64 方便传输 const base64PublicKey arrayBufferToBase64(exportedPublicKey);私钥的导出格式通常用pkcs8Private Key Information Syntax Standard它同样带算法标识是标准化的存储格式。2.2 生成 AES-GCM 会话密钥每次加密都重新生成的临时钥匙会话密钥设计成“一次性”的只服务于某一次具体文件的加密。这样做的好处是即使某个文件被攻破泄露的也只是那个文件对应的密钥不会影响其他文件。const sessionKey await crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] );AES 有三种常见长度128、192、256 位。256 位安全性最高浏览器原生支持也好直接选 256 不纠结。extractable设置为true因为后续要把这个会话密钥导出来用 RSA 公钥去包裹它。导出后的会话密钥是raw格式的二进制数据只有 32 字节。接着就用接收方的 RSA 公钥去加密这一小段数据const rawSessionKey await crypto.subtle.exportKey(raw, sessionKey); const wrappedKey await crypto.subtle.encrypt( { name: RSA-OAEP }, publicKey, rawSessionKey );wrappedKey就是要写进密文容器的wrappedKey字段。整个流程跑完后内存里的sessionKey和rawSessionKey就没什么用了要及时清引用让它被垃圾回收处理掉。2.3 私钥的保存与备份不可导出的密钥怎么恢复私钥是最敏感的数据它一旦丢失所有密文都再也解不开。所以私钥的保存策略必须在设计阶段就想清楚。方案一完全不导出。密钥只存在于内存中用户关掉页面就没有了。安全性极高但实用性为零用户换个设备就解不了密这在文件分享场景里基本不可接受。方案二导出后用口令加密保存。把私钥用pkcs8格式导出然后利用用户输入的口令派生出一个 AES 密钥去加密它加密结果可以放在 IndexedDB 或者允许用户下载成文件备份。需要解密时加载备份文件让用户输入口令解密出私钥再用。方案三加密服务端托管。用户第一次生成密钥对时私钥用口令加密后上传服务器用户跨设备登录时下载加密后的私钥备份在本地输入口令解密。服务端怎么也拿不到明文私钥。这里重点看方案二里的口令派生因为很多实现都在这上面翻车const password userInput; // 用户输入的口令 const encoder new TextEncoder(); const salt crypto.getRandomValues(new Uint8Array(16)); // 每用户每用途不同 const baseKey await crypto.subtle.importKey( raw, encoder.encode(password), PBKDF2, false, [deriveKey] ); const derivedKey await crypto.subtle.deriveKey( { name: PBKDF2, salt: salt, iterations: 600000, hash: SHA-256 }, baseKey, { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] );这里最容易被忽略的是salt盐值。它必须是随机生成的而且每个用户、每个用途都要不同。盐值不需要保密可以和加密后的私钥一起存储。如果不用盐同一个口令永远派生同一个密钥攻击者可以预先算好彩虹表直接碰撞等于把口令安全全毁了。iterations迭代次数同样关键。它的作用是让暴力破解变慢迭代越多破解成本越高。OWASP 对 PBKDF2-HMAC-SHA256 的建议是至少 60 万次浏览器里跑一次大约几百毫秒对用户来说是可以接受的等待就不要省这个时间。另外要注意迭代参数必须存下来将来想提高迭代次数时老数据还能用旧参数解密做平滑升级。3. 文件加密主流程从分块读取到密文落盘3.1 大文件分块读取为什么不能用 arrayBuffer 一把梭我在一开始就踩过一个直观的坑直接用file.arrayBuffer()把整个文件读进内存然后一股脑加密。小文件试起来一切正常一旦换成几百 MB 甚至几个 GB 的视频文件页面直接崩溃内存占用瞬间拉满。浏览器对单个 ArrayBuffer 的内存有上限不同浏览器限制不同但几十 MB 到一两百 MB 的分配基本就到顶了。于是必须分块处理每次只读文件的一个片段加密然后丢进输出列表再处理下一段。在 Web Crypto API 里分块加密的天然伴侣就是Blob.slice()。它不占额外内存只是创建一个指向原文件部分区域的视图const CHUNK_SIZE 1024 * 1024; // 1MB const file fileInput.files[0]; const chunkCount Math.ceil(file.size / CHUNK_SIZE); for (let i 0; i chunkCount; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunkBlob file.slice(start, end); const chunkBuffer await chunkBlob.arrayBuffer(); // 加密 chunkBuffer }块大小怎么定1MB 是经过测算的折中方案。块太小循环次数太多加密每条消息的固定开销占比上升整体性能下降块太大内存占用变高而且任何一块数据损坏损失的范围就越大。实际测试下来512KB 到 4MB 之间性能差距不大但超过 8MB 在低端移动设备上会出现明显卡顿所以选 1MB 最稳妥。3.2 AES-GCM 逐块加密与 AAD 绑定技巧加密每一块之前都要先明确一个至关重要的问题这一块的 IV 是什么AES-GCM 对 IV 的要求极其严格在同一个密钥下IV 绝对不能重复。一旦两个块用了相同的 IV攻击者就可能通过密文的异或运算还原出明文信息这是 GCM 模式最危险的漏洞。所以每个块都要有独一无二的 IV。我在项目里采用的方案是为整个文件生成一个 12 字节96 位的随机 IV 作为基础值然后每个块在这个基础值后面拼接一个 4 字节的块索引组成 16 字节的最终 IV。这样既保证了每个块的 IV 全局唯一又不需要为每个块生成随机数、额外存储每个块的 IV大幅压缩了容器体积。具体加密逻辑如下const baseIv crypto.getRandomValues(new Uint8Array(12)); for (let i 0; i chunkCount; i) { const chunkBuffer await getChunk(i); // 组合 IV基础值 块索引4字节大端序 const iv new Uint8Array(16); iv.set(baseIv, 0); iv.set(intToBytes(i), 12); // AAD把块索引和总数绑进去 const aad new TextEncoder().encode( JSON.stringify({ version: 1, chunkIndex: i, chunkCount: chunkCount, fileNameHash: await sha256(file.name) }) ); const encryptedChunk await crypto.subtle.encrypt( { name: AES-GCM, iv: iv, additionalData: aad, tagLength: 128 }, sessionKey, chunkBuffer ); // encryptedChunk 的最后 16 字节是 GCM 的认证标签 encryptedChunks.push(new Uint8Array(encryptedChunk)); }AADAdditional Authenticated Data附加验证数据是 GCM 模式提供的额外保护能力这些数据参与认证计算但是不被加密解密方会重新计算并验证它们是否被篡改。我在 AAD 里绑定了块索引、总块数、文件名哈希攻击者如果尝试交换密文块的顺序、删掉某一块、把 A 文件当成 B 文件解密认证都会失败。这是一层很多教程不会提、但实际极其关键的防护。这里要尤其注意chunkIndex和chunkCount一旦写进 AAD解密时就必须原样提供缺一个数字都会导致认证失败。所以这些元数据不能只放在 JSON 头部里解密时要先把它们读出再作为 AAD 参数传给解密函数。3.3 用 RSA-OAEP 包裹会话密钥的细节在 2.2 初步提过包裹密钥的基本流程但这里有几个生产环境需要注意的细节。第一RSA-OAEP 加密不是幂等的同样的明文每次加密结果都不同这是 OAEP 填充里的随机性带来的。所以不要奢望“包裹后再解包得到同样的字节”就能证明密钥没被篡改真正校验完整性靠的是解密后的wrappedKey能不能被正确导入为 AES 密钥。第二包裹前再次确认rawSessionKey的长度是 32 字节。如果你好奇AES-GCMlength: 256会导出 32 字节那是正确的。但如果你复用代码时改成了 128 位而忘记这里的影响导出后就是 16 字节RSA-OAEP-2048 也能放下但安全性降级了你可能不知道。最好在导出后加一个显式断言if (rawSessionKey.byteLength ! 32) { throw new Error(会话密钥长度异常期待 256 位 AES 密钥); }第三wrappedKey是 ArrayBuffer要转成 Base64 或 hex 才能放进 JSON 头部。转码工具函数可直接参考常规的 Base64 工具实现注意在处理二进制数组时使用Array.from或forEach逐字节拼接不要直接String.fromCharCode.apply(null, array)大数组会栈溢出。3.4 组装密文与信息落盘头部、分块数据、状态返回所有分块加密完成后就要组装最终密文文件。我用的组装方式是先把头部 JSON 转成字节记录头部长度然后把头部和数据区拼接成同一个 Uint8Array最后包成一个 Blob 提供下载。组装的时候有一个容易踩的坑如果你先把所有encryptedChunks都放进一个数组再一次性拼接成大 Uint8Array内存还是会爆炸。正确做法是边加密边写入 Blobconst header { version: 1, algorithm: AES-GCM, wrappedKey: base64Encode(wrappedKey), iv: base64Encode(baseIv), chunkSize: CHUNK_SIZE, chunkCount: chunkCount, aadMeta: { fileNameHash: await sha256(file.name), originalSize: file.size } }; const headerBytes new TextEncoder().encode(JSON.stringify(header)); const headerLengthBytes intToBytes(headerBytes.length); // 4字节大端序 // 把头部长度 头部 第一个分块先落盘 const blobParts [ headerLengthBytes, headerBytes ]; for (let i 0; i chunkCount; i) { blobParts.push(encryptedChunks[i]); // 把大块数据立即剥离引用避免内存堆积 encryptedChunks[i] null; } const encryptedFile new Blob(blobParts, { type: application/octet-stream }); downloadBlob(encryptedFile, encrypted.pkg);这里我把头部前 4 字节固定为头部长度。解密时先读 4 字节得到头部长度再读头部再读数据区。每个密文文件都自带完整参数只要给定文件路径就能独立解密十分适合文件分享场景。边加密边收集 Blob 片段还有一个额外好处可以顺带实现进度条效果。每加密完一块就用onProgress(chunkIndex / chunkCount)回传进度UI 层再节流更新显示用户体验会好很多。4. 解密流程与细节还原4.1 解密前置步骤解析容器、解包裹会话密钥解密流程是加密流程的逆向操作但细节更磨人因为你要处理各种“那次加密时随手写进去的参数”。第一步是解析容器。拿到密文 Blob 后先读前 4 字节得到头部长度const headerLengthBytes new Uint8Array(await blob.slice(0, 4).arrayBuffer()); const headerLength bytesToInt(headerLengthBytes); const headerBytes await blob.slice(4, 4 headerLength).arrayBuffer(); const header JSON.parse(new TextDecoder().decode(headerBytes));解析完头部马上要校验版本号。如果header.version超出当前程序能处理的范围直接报出不兼容提示不能硬着头皮去解密。然后根据header.algorithm选择对应的解密逻辑目前我们只有AES-GCM一种但代码结构上要留出分支方便后续扩展算法。第二步是解包裹会话密钥。用户加载自己的私钥。如果私钥是从备份文件读入的需要先导入pkcs8格式const privateKey await crypto.subtle.importKey( pkcs8, pkcs8Buffer, { name: RSA-OAEP, hash: SHA-256 }, true, [decrypt] );然后用私钥解密header.wrappedKeyconst wrappedKeyBuffer base64Decode(header.wrappedKey); const rawSessionKey await crypto.subtle.decrypt( { name: RSA-OAEP }, privateKey, wrappedKeyBuffer ); const sessionKey await crypto.subtle.importKey( raw, rawSessionKey, { name: AES-GCM }, false, // 解密用不需要再导出 [decrypt] );这一步往往在“私钥口令错误”的报错排查里花很多时间。如果你发现decrypt阶段一直报OperationError先确认导入私钥没报错再确认wrappedKey在传输过程中没有因为 JSON 换行、Base64 补齐字符等问题被改动。4.2 逐块解密与 AAD 的二次校验拿到会话密钥后就可以按块解密数据区了。数据区从4 headerLength开始每个块长度为chunkSize 16。注意这个16是 GCM 追加的认证标签长度解密输入密文时不能把标签单独拆掉直接整块传给解密函数即可。解密每一块的代码const dataStart 4 headerLength; const blobData blob.slice(dataStart); for (let i 0; i header.chunkCount; i) { const start i * (header.chunkSize 16); const end Math.min(start header.chunkSize 16, blobData.size); const chunkCiphertext await blobData.slice(start, end).arrayBuffer(); const iv new Uint8Array(16); iv.set(base64Decode(header.iv), 0); iv.set(intToBytes(i), 12); const aad new TextEncoder().encode( JSON.stringify({ version: header.version, chunkIndex: i, chunkCount: header.chunkCount, fileNameHash: header.aadMeta.fileNameHash }) ); try { const chunkPlaintext await crypto.subtle.decrypt( { name: AES-GCM, iv: iv, additionalData: aad, tagLength: 128 }, sessionKey, chunkCiphertext ); decryptedChunks.push(new Uint8Array(chunkPlaintext)); } catch (e) { // GCM 认证标签校验失败 throw new Error(第 ${i} 块解密失败密文或附加数据被修改); } }GCM 的解密有一个很爽的特性如果认证失败它不会像某些模式那样只给你错误明文而是直接抛异常。你可以在异常里断定为“数据被篡改或损坏”这是 GCM 内置的完整性校验带来的能力。如果你比较抠性能可以把解密出的明文实时拼接成 Blob而不是收集所有块后再拼接与加密端同样采用流式处理。解密完成后最好用文件头的originalSize把最后的明文裁剪成准确长度去掉因为分块处理可能产生的最后一个 Byte 引起的尾部填充信息。接着计算解出明文的 SHA-256与头部aadMeta.fileNameHash中存的文件名哈希做二次比对。之前 AAD 已经校验过完整性了这里再做一次是为了确认整个文件结构和原始元数据对齐属于“多重保险”。所有解密操作都应该集中放在一个 try/catch 里因为从私钥导入到最后的逐块解密任何一步失败都必须给用户清晰的错误提示私钥错误、文件损坏、格式不兼容都要分门别类而不是只抛一个笼统的“解密失败”。5. 安全加固、兼容性与踩坑排查5.1 安全加固清单随机源、IV 唯一性、元数据校验整个流程跑通后我回头把安全问题又盘了一遍有几条是必须硬性要求写进代码注释的。第一所有随机数必须来自crypto.getRandomValues()。千万不要用Math.random()去生成密钥、盐、IV。Math.random()是伪随机数生成器它的随机性不足以对抗加密攻击。只需要记住一条加密安全的随机数只认crypto.getRandomValues和crypto.subtle.generateKey内部生成的随机数。第二IV 唯一性原则。前面通过“基础 IV 块索引”的方式保证一个文件内的 IV 不重复这里更极端的场景是如果多次加密使用的会话密钥相同跨文件的 IV 也不能重复。但我们的方案里每次加密都会生成新的随机会话密钥新文件自然不可能复用旧密钥所以 IV 唯一性问题从源头上就不存在了。这个设计逻辑要理解透。第三AAD 里必须包含语义信息。不要只绑一个块索引建议绑定格式版本、文件哈希、块总数。攻击者无法在不影响认证的情况下对密文做任何结构调整。如果你将来想支持“只加密文件部分内容”这类高级功能一定要先在 AAD 里设计好对应的数据约束。第四头部字段做白名单解析。解析 JSON 头部时不要无条件信任所有字段。只读取预期内的字段忽略未知字段同时对关键字段做类型和范围校验。例如chunkCount必须为正整数且不为 0chunkSize不能超过某个上限。防止恶意构造的密文文件把解密端拖入异常循环或超大内存分配。加固清单可以参考下面的速查表风险点错误做法正确做法随机数来源Math.random()crypto.getRandomValues密钥长度AES-128 或 RSA-1024AES-256RSA-2048IV 管理固定 IV 或全局随机短 IV基础 IV 块索引组合每个块唯一完整性校验只加密不认证AES-GCM 自带认证标签元数据防篡改依赖外部系统AAD 绑定文件名哈希、块索引、块总数私钥备份明文导出口令 PBKDF2 派生密钥加密后存储口令派生盐值空盐或固定盐每用户每用途 16 字节随机盐头部解析全量信任白名单字段 类型/范围校验5.2 浏览器兼容性与环境要求Web Crypto API 的兼容性在主流浏览器中都很好但并不代表所有环境都一步到位。我实际遇到的兼容性问题集中在几处一是安全上下文限制前面提过HTTP 页面下crypto.subtle不可用这个必须在项目初始化时做检测并在 UI 上给出明确提示否则用户会一脸懵地发现问题怎么都用不了。二是导出格式的差异。老版本 Safari 对 RSA-OAEP 的 SHA-256 支持存在差异出现兼容问题时可以回退到 SHA-1 填充但这会降低安全性尽量只在必要时使用。更稳妥的做法不是去兼容老版本浏览器而是把浏览器版本要求直接写进项目文档并做 feature 检测。三是Blob.prototype.arrayBuffer的支持。较旧环境只支持FileReader需要提供统一封装async function blobToArrayBuffer(blob) { if (blob.arrayBuffer) { return await blob.arrayBuffer(); } // 旧环境回退方案 return await new Promise((resolve, reject) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.onerror reject; reader.readAsArrayBuffer(blob); }); }四是 IndexedDB 存私钥时要注意不能把CryptoKey对象直接存储它是不可结构克隆的对象。必须先把密钥导出成pkcs8或jwk格式再存到 IndexedDB 里。读取时重新importKey回来。5.3 常见报错速查表与排查思路把我在整个调试过程中遇到的高频报错整理成表格遇到问题可以直接对照排查报错信息可能原因排查思路crypto.subtle is undefined非安全上下文确认页面走 HTTPS 或 localhostDataError导入的密钥格式错误或长度不对检查 Base64 转回字节是否正确密钥长度是否匹配OperationError解密失败GCM 认证标签校验失败检查 IV、AAD、密文是否被改动块索引是否对得上InvalidAccessError密钥用途与操作不匹配检查 generateKey 时 keyUsages 是否包含对应操作NotSupportedError算法或参数不被支持确认浏览器版本是否较旧尝试回退参数QuotaExceededErrorIndexedDB 存储超限拆分成多条记录存储或提示用户清理空间Memory pressure一次性读入过大文件改用分块读取避免整文件 arrayBufferJSON.parse异常头部 JSON 损坏检查头部长度字节是否被意外修改排查有个基本原则先检查环境再检查格式最后检查密钥。环境问题如安全上下文最容易一眼定位格式问题用“能不能成功导入头部 JSON”作为分割线密钥问题则集中在解包裹和导入环节任何一步都配合console输出中间变量长度来定位。我最后再分享一个调试习惯加密模块和解密模块一定要分开写测试。只用“加密后能解密回来”这一个用例远远不够必须去手动篡改密文、篡改 IV、篡改 AAD、换一个不匹配的私钥确认每一步都走向正确的报错分支。这样才能打包票说这套流程在真实世界里遇到异常文件时依然稳得住。前端加密是个一旦上线就不容易回头的功能前期把边界测透了后面才能真正省心。