基于Angular结合硬件安全密钥(Titan、YubiKey等)实现E2EE的疑问
WebAuthn硬件密钥结合E2EE的正确实现路径
嘿,我来帮你理清楚这里的问题——首先你踩了一个WebAuthn的常见误区,咱们一步步拆解:
核心误解:WebAuthn公私钥不能直接用来做常规加解密
你当前的思路里有个关键错误:WebAuthn生成的私钥是硬件/浏览器安全存储的,永远不会暴露给前端JS代码,这是WebAuthn的核心安全设计。所以你没法直接获取私钥来解密数据,这路子走不通。
而且WebAuthn的密钥对本质是为**身份验证(签名/验证)**设计的,不是用来直接加密业务数据的。如果要结合E2EE,正确的姿势是用WebAuthn来保护你的AES对称密钥(也就是你原来用CryptoJS的那个key),而不是替换AES。
正确的实现流程(适配你的需求)
因为你原本就用AES做E2EE,我们可以把WebAuthn作为AES密钥的安全托管方案:
生成随机AES密钥:这是用来加密实际业务数据的密钥,用Web Crypto API生成比手动指定更安全:
const aesKey = await window.crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, // 允许导出,后续用于密钥封装 ["encrypt", "decrypt"] );用WebAuthn公钥封装AES密钥:
注册硬件密钥时,要指定密钥支持「密钥封装」算法(比如RSA-OAEP或ECDH-ES),示例注册配置:const publicKeyOptions = { challenge: new Uint8Array([/* 服务器生成的随机挑战值 */]), rp: { name: "你的应用名称" }, user: { id: new Uint8Array([/* 用户唯一标识,比如哈希后的用户ID */]), name: "user@example.com", displayName: "用户名" }, pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256(椭圆曲线签名,用于身份验证) { type: "public-key", alg: -37 } // RSA-OAEP-256(用于密钥封装) ], authenticatorSelection: { authenticatorAttachment: "cross-platform", // 指定硬件密钥 userVerification: "required" } }; // 创建硬件密钥凭证 const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }); // 导入公钥,用于封装AES密钥 const publicKey = await window.crypto.subtle.importKey( "raw", credential.response.getPublicKey(), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["wrapKey"] ); // 封装AES密钥(把AES密钥用公钥加密,只有对应私钥能解) const wrappedAesKey = await window.crypto.subtle.wrapKey( "raw", aesKey, publicKey, { name: "RSA-OAEP" } );存储数据到数据库:
你需要存储的内容包括:credential.id:不需要加密,这是公开的凭证标识,用来后续找到对应的密钥- 用AES密钥加密后的业务数据(你原来的
CryptoJS.AES.encrypt(data, key).toString()逻辑可以保留,建议换成Web Crypto API的AES-GCM更安全) - 封装后的AES密钥
wrappedAesKey(转成base64格式存储)
解密流程:
当需要解密时,先通过credential.id找到对应的wrappedAesKey,然后:const getOptions = { challenge: new Uint8Array([/* 服务器生成的新挑战值 */]), allowCredentials: [{ type: "public-key", id: Uint8Array.from(atob(credentialId), c => c.charCodeAt(0)) }], userVerification: "required" }; // 获取硬件密钥断言(触发用户验证,比如触摸密钥) const assertion = await navigator.credentials.get({ publicKey: getOptions }); // 导入私钥句柄(注意:私钥不会暴露,只是拿到可操作的安全引用) const privateKey = await window.crypto.subtle.importKey( "pkcs8", // 实际要从注册时存储的公钥凭证元数据中提取私钥相关材料,这里简化示例 assertion.response.getAuthenticatorData(), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["unwrapKey"] ); // 解封装得到原始AES密钥 const aesKey = await window.crypto.subtle.unwrapKey( "raw", Uint8Array.from(atob(wrappedAesKey), c => c.charCodeAt(0)), privateKey, { name: "RSA-OAEP" }, { name: "AES-GCM", length: 256 }, true, ["decrypt"] ); // 用AES密钥解密业务数据(如果用CryptoJS,需要把密钥转成它能识别的格式) const decryptedData = CryptoJS.AES.decrypt(encryptedData, aesKeyToString(aesKey)).toString(CryptoJS.enc.Utf8);
关于你的credential.id存储问题
其实credential.id不需要加密存储,它就是用来唯一标识用户硬件密钥凭证的公开值。如果出于特殊原因一定要加密它,应该用你的AES密钥加密,而不是WebAuthn公钥——因为公钥加密的内容只能通过私钥解密,但私钥拿不出来,只能通过上面的解封装流程间接处理,完全没必要绕这个弯。
额外建议
- 尽量用原生的Web Crypto API代替CryptoJS,它更安全且是浏览器原生支持的标准实现
- 所有WebAuthn相关的
challenge(挑战值)必须由服务器生成,不能前端自行生成,防止重放攻击
内容的提问来源于stack exchange,提问作者Engineer
相关产品推荐
相关产品推荐

