基于Web Crypto API的浏览器端到端加密通信实战指南

📅 2026/6/17 20:45:57
基于Web Crypto API的浏览器端到端加密通信实战指南
1. 项目概述为什么要在浏览器里搞端到端加密最近几年数据泄露事件层出不穷大家对自己的聊天记录、文件传输越来越不放心。传统的网络应用数据从你的浏览器发到服务器再从服务器发到对方这中间的链路服务端是能“看见”明文的。这意味着理论上服务提供方、甚至是攻破了服务器的攻击者都能获取你的隐私内容。端到端加密就是为了解决这个问题让数据在发送方加密只有接收方才能解密中间的服务器、网络链路拿到的只是一堆无法解读的乱码。你可能听说过 Signal、WhatsApp 这些通讯软件用了端到端加密但它们多是原生应用。而在 Web 领域我们以前会觉得这很难实现毕竟 JavaScript 运行在不受控的浏览器环境里。但现在情况不同了现代浏览器普遍支持了Web Crypto API这是一个划时代的原生接口让我们能在前端直接进行高性能的加密解密操作而无需依赖任何第三方插件或服务器中转密钥。这个项目就是要利用 Web Crypto API在纯浏览器环境里搭建一套完整的、可实操的端到端加密通信方案。它不只是一个概念演示我会带你从密钥生成、交换、到消息的加密、解密、完整性验证一步步实现并分享在实际编码中踩过的坑和必须注意的安全细节。无论你是想为自己的在线协作工具增加隐私保护还是好奇现代 Web 安全的前沿实现这篇文章都能给你一套可以直接“抄作业”的代码和清晰的设计思路。2. 核心设计一套完整的端到端加密通信方案拆解在动手写代码之前我们必须把整个方案的骨架和设计思路理清楚。端到端加密不是简单调用一个encrypt函数它是一套包含密钥管理、协商、协议设计的系统工程。2.1 方案架构与协议选型我们的目标是实现两个浏览器客户端之间的安全通信。假设有一个服务端但它只负责转发加密后的消息密文而绝不接触任何解密密钥或明文。整个架构可以概括为客户端 A和客户端 B各自在本地生成非对称密钥对公钥和私钥。双方通过一个可信的、或至少是可验证的渠道交换公钥。在实际项目中这个渠道通常是通过服务端数据库存储并分发公钥但核心是服务端不参与密钥生成。A想给B发消息时使用B 的公钥加密一个临时生成的对称密钥会话密钥再用这个会话密钥加密实际的消息内容。最后将加密后的会话密钥和加密后的消息密文一起发送出去。B收到后先用自己的私钥解密出会话密钥再用会话密钥解密出原始消息。这里涉及两个关键的密码学概念非对称加密和对称加密。非对称加密如 RSA-OAEP, ECDH用于安全地交换密钥因为它用公钥加密的数据只能用对应的私钥解密非常适合在不安全信道传递秘密。但它的计算开销大不适合加密大量数据。对称加密如 AES-GCM速度快适合加密实际的消息体但前提是双方必须拥有同一个密钥。因此常见的模式是“非对称加密传递对称密钥对称加密处理业务数据”。为什么选择AES-GCM作为对称加密算法因为它同时提供了加密和认证功能。GCM 模式会在加密的同时生成一个认证标签在解密时验证密文是否被篡改这比单纯的加密模式如 CBC更安全、更高效。对于非对称加密我们将使用RSA-OAEP用于密钥封装因为它能抵抗选择密文攻击是目前 Web Crypto API 中推荐的 RSA 加密模式。2.2 密钥生命周期管理密钥管理是安全系统的基石也是最容易出错的地方。在我们的方案中主要管理两类密钥长期身份密钥对每个用户客户端在初始化时生成一次并持久化存储。公钥上传到服务器供他人获取私钥必须绝对保密地存储在客户端本地。私钥绝不能以任何形式发送到网络或服务器。临时会话密钥每次发起新会话或定期时动态生成用于加密本次通信的实际数据。使用完毕后即丢弃实现前向保密——即使长期私钥未来泄露过去的通信记录也无法被解密。在浏览器端我们使用crypto.subtle.generateKey来生成这些密钥。对于长期密钥我们需要将其导出为某种格式如jwk或spki/pkcs8并安全存储。这里强烈建议使用浏览器的IndexedDB进行存储而不是localStorage。localStorage是同步的且存储空间有限更重要的是它容易被同源下的 XSS 攻击窃取。而IndexedDB是异步的容量更大并且可以配合CryptoKey对象的非提取性属性让密钥更安全地留在浏览器的加密上下文中。注意Web Crypto API 生成的CryptoKey对象本身是不透明的你不能直接读取它的密钥材料。当你选择“导出”密钥时务必使用crypto.subtle.exportKey()方法并谨慎选择导出格式。导出后的密钥材料如 JWK 字符串就变成了明文秘密存储时必须格外小心。3. 实战演练使用 Web Crypto API 一步步实现核心功能理论讲完了我们进入实战环节。我会用具体的代码示例展示每一个关键步骤并解释每个参数的意义和选择它的原因。3.1 环境准备与密钥生成首先确保你的页面通过HTTPS协议提供服务或者在localhost本地开发。这是 Web Crypto APIcrypto.subtle能够使用的强制安全上下文要求。第一步生成用户的长期 RSA 密钥对。async function generateUserKeyPair() { try { const keyPair await window.crypto.subtle.generateKey( { name: RSA-OAEP, modulusLength: 2048, // 密钥长度2048位是当前安全的最低要求4096更安全但更慢 publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 公共指数通常就是65537 hash: SHA-256, // 与OAEP填充方案配套使用的哈希函数 }, true, // 是否可导出。这里设为true因为我们需要导出公钥进行交换。 [encrypt, decrypt] // 密钥用途公钥加密私钥解密 ); console.log(RSA密钥对生成成功); return keyPair; } catch (err) { console.error(生成RSA密钥对失败:, err); throw err; } }这段代码生成了一个 2048 位的 RSA 密钥对。modulusLength是关键参数直接关系到安全性。虽然 1024 位已被认为不安全但 2048 位目前仍是主流。如果你需要更高的安全级别可以考虑 4096 位但请注意加解密性能会显著下降。第二步导出并存储公钥。生成密钥对后我们需要将公钥导出为一种可以传输的格式如 JWK 或 SPKI发送到服务器。async function exportPublicKey(publicKey) { const exported await window.crypto.subtle.exportKey( jwk, // 导出格式JSON Web Key易于JSON传输 publicKey ); // 删除 JWK 中的敏感字段私钥才有仅保留公钥部分 const publicKeyJwk { kty: exported.kty, n: exported.n, e: exported.e, alg: exported.alg, ext: exported.ext, }; return publicKeyJwk; } // 假设我们将 publicKeyJwk 这个 JSON 对象发送到服务器保存第三步安全保存私钥。私钥绝不能离开客户端。我们将其导出为pkcs8格式或保持为CryptoKey对象存入 IndexedDB。async function savePrivateKeyToIndexedDB(privateKey, userId) { // 首先将私钥导出为可存储的格式这里用jwk示例实际存储可加密 const exportedPrivateKey await window.crypto.subtle.exportKey(jwk, privateKey); // 打开 IndexedDB 数据库 const request indexedDB.open(E2EEncryptionDB, 1); request.onupgradeneeded (event) { const db event.target.result; if (!db.objectStoreNames.contains(keys)) { db.createObjectStore(keys, { keyPath: id }); } }; request.onsuccess (event) { const db event.target.result; const transaction db.transaction(keys, readwrite); const store transaction.objectStore(keys); store.put({ id: privateKey_${userId}, key: exportedPrivateKey }); }; }实操心得在实际产品中直接存储导出的 JWK 格式私钥仍然有风险。更安全的做法是使用一个由用户密码派生的密钥通过 PBKDF2对导出的私钥材料进行二次加密然后再存储。这样即使数据库泄露攻击者没有用户密码也无法解密私钥。3.2 会话建立与密钥交换当用户 A 想要和用户 B 开始安全聊天时需要建立一个共享的会话密钥。我们采用混合加密方式A 生成一个随机的 AES 会话密钥。A 获取 B 的公钥从服务器并用它加密这个会话密钥。A 将加密后的会话密钥发送给 B。B 用自己的私钥解密得到会话密钥。第一步生成 AES-GCM 会话密钥。async function generateSessionKey() { return await window.crypto.subtle.generateKey( { name: AES-GCM, length: 256, // AES-256提供足够的安全强度 }, true, // 可导出方便后续封装传输 [encrypt, decrypt] // 用于加密和解密数据 ); }第二步使用 RSA-OAEP 封装加密会话密钥。假设我们已经从服务器拿到了 B 的公钥 JWK 对象bPublicKeyJwk并已将其导入为CryptoKey对象bPublicKey。async function encryptSessionKey(sessionKey, recipientPublicKey) { // 首先导出会话密钥的原始材料 const rawSessionKey await window.crypto.subtle.exportKey(raw, sessionKey); // 使用接收方的公钥加密这个原始密钥材料 const encryptedKey await window.crypto.subtle.encrypt( { name: RSA-OAEP, }, recipientPublicKey, // B的公钥 rawSessionKey // 待加密的会话密钥材料 ); // encryptedKey 是一个 ArrayBuffer需要转换为可传输的格式如 Base64 return arrayBufferToBase64(encryptedKey); } // ArrayBuffer 转 Base64 的工具函数 function arrayBufferToBase64(buffer) { const bytes new Uint8Array(buffer); let binary ; for (let i 0; i bytes.byteLength; i) { binary String.fromCharCode(bytes[i]); } return window.btoa(binary); }第三步B 端解密会话密钥。B 收到加密的会话密钥后用自己的私钥解密。async function decryptSessionKey(encryptedKeyBase64, myPrivateKey) { // 将 Base64 字符串转换回 ArrayBuffer const encryptedKeyBuffer base64ToArrayBuffer(encryptedKeyBase64); // 使用自己的私钥解密 const rawSessionKey await window.crypto.subtle.decrypt( { name: RSA-OAEP, }, myPrivateKey, encryptedKeyBuffer ); // 将解密出的原始密钥材料导入为 CryptoKey 对象 return await window.crypto.subtle.importKey( raw, rawSessionKey, { name: AES-GCM, length: 256, }, true, [encrypt, decrypt] ); } // Base64 转 ArrayBuffer 的工具函数 function base64ToArrayBuffer(base64) { const binaryString window.atob(base64); const len binaryString.length; const bytes new Uint8Array(len); for (let i 0; i len; i) { bytes[i] binaryString.charCodeAt(i); } return bytes.buffer; }至此双方已经安全地共享了一个只有他们俩知道的 AES 会话密钥。这个密钥将用于后续所有消息的加密和解密。3.3 消息的加密、传输与解密有了共享的会话密钥加密和解密消息就相对直接了。这里的关键是正确使用 AES-GCM 的初始化向量IV和认证。加密一条文本消息async function encryptMessage(message, sessionKey) { // 1. 将文本编码为 Uint8Array const encoder new TextEncoder(); const data encoder.encode(message); // 2. 生成一个随机的 12 字节初始化向量 (IV)。对于 GCM 模式每次加密都必须使用新的 IV且绝对不能重复使用。 const iv window.crypto.getRandomValues(new Uint8Array(12)); // 3. 执行加密 const encryptedContent await window.crypto.subtle.encrypt( { name: AES-GCM, iv: iv, // 传入 IV // 可以添加 additionalData可选用于认证但不加密的数据 // additionalData: encoder.encode(metadata), }, sessionKey, data ); // 4. 将 IV 和加密后的密文组合在一起进行传输 // IV 本身不是秘密可以明文传输但必须和密文一起送达接收方。 const combined new Uint8Array(iv.length encryptedContent.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(encryptedContent), iv.length); return arrayBufferToBase64(combined.buffer); }重要安全提示AES-GCM 的 IV 绝对不能重复使用如果使用同一个(key, IV)组合加密两条不同的消息会严重破坏安全性可能导致密钥泄露。因此每次加密都必须使用密码学安全的随机数生成器如crypto.getRandomValues生成全新的 IV。解密消息接收方收到 Base64 编码的组合数据后需要先分离出 IV 和密文然后解密。async function decryptMessage(encryptedCombinedBase64, sessionKey) { // 1. 解码 Base64得到组合的 ArrayBuffer const combinedBuffer base64ToArrayBuffer(encryptedCombinedBase64); const combined new Uint8Array(combinedBuffer); // 2. 分离 IV前12字节和密文 const iv combined.slice(0, 12); const ciphertext combined.slice(12); // 3. 执行解密 try { const decryptedData await window.crypto.subtle.decrypt( { name: AES-GCM, iv: iv, // 如果加密时有 additionalData这里必须提供完全相同的值否则解密会失败 // additionalData: encoder.encode(metadata), }, sessionKey, ciphertext ); // 4. 将解密后的 ArrayBuffer 解码为文本 const decoder new TextDecoder(); return decoder.decode(decryptedData); } catch (error) { console.error(解密失败:, error); // 解密失败可能原因密钥错误、IV错误、密文被篡改、additionalData不匹配。 throw new Error(消息解密失败可能已被篡改或密钥不正确。); } }AES-GCM 的解密过程同时完成了完整性验证。如果密文或 IV 在传输过程中被修改了一丁点decrypt操作就会抛出异常。这为我们提供了消息认证确保收到的消息就是对方发送的原始消息。4. 系统集成与通信协议设计现在我们已经有了所有密码学原语需要将它们组装成一个可工作的通信系统。这涉及到客户端-服务端的交互协议设计。4.1 消息信封格式设计我们需要定义一种数据结构来包裹加密后的消息以及必要的元数据以便接收方能正确处理。一个典型的端到端加密消息信封可能包含以下字段{ senderId: user_a, recipientId: user_b, timestamp: 1627891234567, type: session_init, // 或 message payload: { encryptedSessionKey: Base64EncodedString..., // 仅type为session_init时需要 encryptedMessage: Base64EncodedString..., algorithm: AES-GCM, keyEncryptionAlgorithm: RSA-OAEP } }type: session_init表示这是一条初始化消息其payload.encryptedSessionKey包含了用接收方公钥加密的 AES 会话密钥。接收方处理此类消息后会在本地保存这个会话密钥用于后续解密type: message的消息。type: message表示这是一条常规聊天消息。接收方使用已协商好的会话密钥来解密payload.encryptedMessage。服务端的角色非常“笨”它只负责验证用户身份通过常规的登录 Token。接收消息信封根据recipientId将其存入对应收件人的消息队列或直接推送。不解析、不查看payload内的任何加密内容。4.2 客户端状态管理与会话恢复在实际聊天中用户可能刷新页面或重新打开浏览器。我们需要设计会话恢复机制。长期密钥的持久化用户的 RSA 私钥必须持久化存储在 IndexedDB 中并在应用启动时尝试加载。会话密钥的缓存与每个联系人的当前会话密钥可以临时存储在内存或sessionStorage中。页面刷新后会话密钥丢失需要重新发起session_init流程。为了改善体验可以考虑将会话密钥也加密后存入 IndexedDB用用户的主密钥加密但这增加了复杂性。消息顺序与重放攻击简单的实现可能无法抵御消息重放攻击。可以在消息信封中加入一个递增的序列号或使用时间戳并在接收方进行校验拒绝处理已经处理过或时间戳过旧的消息。一个简单的会话管理逻辑示例class SecureMessenger { constructor(userId) { this.userId userId; this.privateKey null; this.sessionKeys new Map(); // 联系人ID - 会话密钥 } async initialize() { // 1. 尝试从 IndexedDB 加载私钥 this.privateKey await this.loadPrivateKeyFromDB(); if (!this.privateKey) { // 2. 如果没有则生成新的密钥对并保存 const keyPair await generateUserKeyPair(); this.privateKey keyPair.privateKey; await this.saveKeyPairToDB(keyPair); // 3. 上传公钥到服务器 const publicKeyJwk await exportPublicKey(keyPair.publicKey); await api.uploadPublicKey(this.userId, publicKeyJwk); } } async startSessionWith(contactId) { // 1. 从服务器获取联系人的公钥 const contactPublicKeyJwk await api.fetchPublicKey(contactId); const contactPublicKey await importPublicKey(contactPublicKeyJwk); // 2. 生成新的会话密钥 const sessionKey await generateSessionKey(); // 3. 用联系人的公钥加密会话密钥 const encryptedSessionKey await encryptSessionKey(sessionKey, contactPublicKey); // 4. 构建 session_init 信封并发送 const envelope { senderId: this.userId, recipientId: contactId, timestamp: Date.now(), type: session_init, payload: { encryptedSessionKey: encryptedSessionKey, algorithm: AES-GCM, keyEncryptionAlgorithm: RSA-OAEP } }; await api.sendMessage(envelope); // 5. 本地保存会话密钥 this.sessionKeys.set(contactId, sessionKey); console.log(与 ${contactId} 的会话已初始化); } async sendMessage(contactId, text) { const sessionKey this.sessionKeys.get(contactId); if (!sessionKey) { throw new Error(未找到与 ${contactId} 的会话密钥请先初始化会话。); } const encryptedMessage await encryptMessage(text, sessionKey); const envelope { senderId: this.userId, recipientId: contactId, timestamp: Date.now(), type: message, payload: { encryptedMessage: encryptedMessage, algorithm: AES-GCM } }; await api.sendMessage(envelope); } async receiveMessage(envelope) { if (envelope.recipientId ! this.userId) return; switch (envelope.type) { case session_init: // 用自己的私钥解密会话密钥 const sessionKey await decryptSessionKey( envelope.payload.encryptedSessionKey, this.privateKey ); this.sessionKeys.set(envelope.senderId, sessionKey); console.log(收到来自 ${envelope.senderId} 的会话初始化密钥已保存。); break; case message: const sessionKeyForSender this.sessionKeys.get(envelope.senderId); if (!sessionKeyForSender) { console.warn(收到来自 ${envelope.senderId} 的消息但无会话密钥。可能需要请求重发session_init。); return; } try { const decryptedText await decryptMessage( envelope.payload.encryptedMessage, sessionKeyForSender ); console.log(来自 ${envelope.senderId} 的消息:, decryptedText); // 触发UI更新... } catch (e) { console.error(解密来自 ${envelope.senderId} 的消息失败:, e); } break; } } }5. 安全考量、常见陷阱与进阶优化实现基本功能只是第一步要构建一个真正安全的系统必须深入考虑以下问题。5.1 关键安全威胁与防御中间人攻击MITM我们的方案假设公钥交换渠道是可信的。如果攻击者在用户 A 获取 B 的公钥时用自己的公钥替换了 B 的公钥那么他就能解密所有发给 B 的消息。防御方法引入公钥指纹验证。在交换公钥后双方通过另一个可信渠道例如已经在使用的、经过验证的通讯方式比对公钥的指纹如 SHA-256 哈希值。许多安全通讯应用都采用这种方式比如显示一串可读的单词或二维码让用户手动比对。前向保密Forward Secrecy我们当前的方案如果用户的长期 RSA 私钥泄露攻击者可以用它解密所有之前截获的、用对应公钥加密的会话密钥从而解密所有历史消息。优化方案采用基于椭圆曲线的迪菲-赫尔曼密钥交换ECDH。每次会话时双方临时生成一对 ECDH 密钥交换公钥然后通过对方的公钥和自己的私钥计算出一个共享秘密再从中派生会话密钥。会话结束后临时私钥立即销毁。这样即使长期身份密钥泄露过去的会话密钥也无法恢复。Web Crypto API 完全支持ECDH算法。密钥存储安全如前所述浏览器端的密钥存储是薄弱环节。XSS 攻击可以读取localStorage甚至可能窃取 IndexedDB 中的数据。最佳实践使用HttpOnly、Secure、SameSite的 Cookie 来防御 XSS 窃取身份令牌。考虑使用Web Workers在独立线程中处理密钥操作减少主线程被 XSS 攻击后直接访问密钥的风险。对于极高安全要求的场景可以引导用户使用硬件安全模块HSM或平台提供的安全 enclave但这在 Web 端支持有限。5.2 性能优化与兼容性非对称加密的性能RSA 2048 位加密解密在移动设备上可能成为性能瓶颈尤其是频繁发送消息时。优化会话建立后应复用会话密钥一段时间例如一小时而不是每条消息都重新协商。考虑使用 ECDSA 进行签名和 ECDH 进行密钥交换椭圆曲线算法在相同安全强度下比 RSA 速度快、密钥短。Web Crypto API 支持P-256secp256r1、P-384等曲线。兼容性Web Crypto API 的crypto.subtle接口在现代浏览器中得到了广泛支持Chrome 37, Firefox 34, Safari 11。但对于旧版浏览器或某些特殊环境需要有降级方案或提示用户升级。务必在使用前进行特性检测if (!window.crypto || !window.crypto.subtle) { alert(您的浏览器不支持 Web Crypto API无法使用端到端加密功能。请使用 Chrome、Firefox、Safari 或 Edge 的最新版本。); // 或者回退到不加密的通信模式不推荐 }数据序列化Web Crypto API 操作的数据多是ArrayBuffer。在与 JSON 格式的 API 交互时需要频繁进行 Base64 或 Hex 编码转换。选择一种高效的编码库如atob/btoa或Uint8Array转换很重要避免成为性能热点。5.3 调试与问题排查实录在实际开发中你几乎一定会遇到各种错误。以下是一些常见问题及排查思路问题现象可能原因排查步骤DOMException: The operation is not supported1. 未在 HTTPS 或 localhost 下运行。2. 使用了浏览器不支持的算法或参数。3. 密钥的用途usages与操作不匹配。1. 检查页面协议。2. 查阅 MDN 兼容性表 确认算法支持。3. 检查生成或导入密钥时指定的usages是否包含当前操作如用只用于encrypt的密钥去调用decrypt。DOMException: The provided data is too large使用 RSA-OAEP 加密的数据过长。RSA 有最大加密长度限制例如2048位密钥对应 ~190字节。确保只用 RSA 加密对称密钥如 32字节的 AES-256 密钥而不是加密整个消息。消息本身用 AES 加密。解密失败抛出异常1. 密钥不匹配发收双方使用的密钥不对应。2. IV 重复使用或损坏。3. 密文在传输中被篡改。4. AES-GCM 解密时additionalData不匹配。1. 确认双方使用的公钥/私钥是否正确配对。2. 确认加密时每次生成了新的随机 IV且解密时使用了正确的 IV。3. 检查网络传输或序列化/反序列化过程是否有误。4. 确认加密和解密时传入的additionalData完全一致如果使用了的话。导入公钥/私钥失败JWK 格式错误或缺少必要字段。仔细检查 JWK 对象的结构。公钥通常需要kty,n,e等字段私钥需要更多字段。使用JSON.stringify打印出来比对。IndexedDB 存储失败数据库版本升级逻辑问题或存储空间不足。检查onupgradeneeded事件处理逻辑。在存储前检查 Quota。一个真实的踩坑记录我曾遇到一个诡异的问题在 Chrome 上一切正常但在某版本 Firefox 上解密总是失败。最后发现是因为在将 IV 和密文拼接成Uint8Array时我错误地使用了ArrayBuffer的切片导致内存视图错位。解决方案是统一使用Uint8Array进行操作并在转换时格外小心ArrayBuffer和Uint8Array的差异。这提醒我们涉及二进制数据操作时务必在不同浏览器上进行测试。6. 总结与展望走完这一整套流程你会发现利用 Web Crypto API 在浏览器中实现端到端加密虽然在细节上颇为繁琐但路径是清晰可行的。我们从最基础的密钥生成、导出导入开始到完成非对称加密交换对称密钥再到使用 AES-GCM 对消息进行加密和认证最后将这些模块整合成一个有状态、可管理的通信客户端。这套方案的核心优势在于其“纯粹性”——所有加密解密操作都在用户浏览器内完成服务端沦为纯粹的“哑管道”从根本上消除了服务端数据泄露的风险。这对于构建需要高度隐私保护的 Web 应用如在线医疗咨询、秘密投票、企业机密通信等场景提供了强大的原生技术支撑。当然本文实现的还是一个简化模型。一个生产级的系统还需要考虑更多如何优雅地处理用户设备丢失或密钥丢失后的恢复如何实现群组聊天的端到端加密如何将消息签名用于不可否认性也集成进来这些都可以在现有基础上进行扩展。例如集成Ed25519算法进行数字签名使用X3DH协议改进双轨密钥交换等。我个人在实现这类系统时最深的体会是密码学工具是精密的乐高积木但如何将它们正确、牢固地拼接在一起才是安全工程真正的挑战。一个微小的失误比如 IV 重用、错误的密钥用途设置都可能让整个安全大厦轰然倒塌。因此在编写每一行密码学相关代码时都要抱有敬畏之心充分测试并尽可能参考和遵循像 Signal 协议这样的、经过时间检验的成熟设计。最后再分享一个小技巧在开发调试阶段可以尝试使用window.crypto.subtle的wrapKey和unwrapKey方法。它们可以模拟“加密一个密钥”的过程并且能直接输出和输入CryptoKey对象有时比手动处理exportKey-encrypt-decrypt-importKey的流程更直观有助于你理解密钥封装的概念。但请注意它们底层使用的仍然是本文介绍的这些加密原语。