密钥交换技术原理与实战中的常见陷阱解析

Good“洛熙 安全 阅读 2,018
赞 28 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

去年底上线一个面向企业客户的加密文件共享模块,前端要跟后端做 ECDH 密钥交换,再用协商出的 AES 密钥加密上传。本来以为就是调个 WebCrypto API,结果首屏加载后点“上传”按钮,光是密钥协商这一步就要等 4–5 秒——用户还没开始选文件,页面就卡住不动了,控制台里还飘着几个 DOMException: The operation is insecure。客户那边已经开始发邮件问“是不是服务挂了”。我盯着 DevTools 的 Performance 面板看了半小时,发现 90% 时间都堆在 crypto.subtle.deriveKeygenerateKey 上,尤其在 iOS Safari 下,生成 P-256 椭圆曲线密钥对平均耗时 3.2s(没错,不是毫秒,是秒)。

密钥交换技术原理与实战中的常见陷阱解析

找到瘼颈了!

一开始我以为是算法选错了,换成 RSA-OAEP?更慢,2048 位直接干到 6s+。后来用 console.time 把每一步拆开打点,定位到真凶:不是协商逻辑慢,是每次上传都从零开始生成新的密钥对(generateKey('ECDH', true, ['deriveKey'])),而且没做任何缓存或复用。更坑的是,我们前端居然在每次上传前都重新 fetch 公钥(后端 /api/v1/public-key 接口),而这个接口本身还带了个 JWT 鉴权中间件,多绕一圈,RTT 加上密钥生成,妥妥的性能黑洞。

工具上就三样:Chrome 的 Performance 录制(勾上 “WebAssembly” 和 “JavaScript samples”)、Safari 的 Web Inspector(iOS 真机连 Mac 调试)、还有自己写的简单计时器:

const t0 = performance.now();
await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']);
console.log('generateKey:', performance.now() - t0);

测完一拍大腿:这不是加密,这是给用户上刑。

优化后:流畅多了

试了几种方案:

  • 方案一:把密钥对存在 IndexedDB —— 不行,私钥不能导出,extractable: false 是硬限制;
  • 方案二:用 Service Worker 缓存公钥响应 —— 有用但治标不治本,生成环节还是卡;
  • 方案三:预生成 + 复用密钥对(最终落地版)—— 效果最好,改动最小,也最稳。

核心思路就一条:别让用户点上传才开始造轮子。我们在页面初始化、用户登录成功后,就异步预生成一套 ECDH 密钥对,并把公钥立刻 POST 到后端存起来(/api/v1/register-public-key),同时本地用 structuredClone(注意 Safari 16.4+ 才支持)或序列化成 JWK(只存公钥部分)缓存一份。后续所有上传请求,直接复用这套密钥对做 deriveKey,不再 generateKey。

关键代码就这几行,加在登录成功回调里:

// 登录成功后立即预生成
async function preloadEcdhKeys() {
  try {
    const keyPair = await crypto.subtle.generateKey(
      { name: 'ECDH', namedCurve: 'P-256' },
      true,
      ['deriveKey']
    );
    
    // 提取公钥并上传
    const pubKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
    await fetch('https://jztheme.com/api/v1/register-public-key', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ publicKey: pubKey })
    });

    // 本地缓存私钥句柄(不可导出,仅可引用)
    window.__ecdh_keypair = keyPair;
    console.log('✅ ECDH keypair preloaded');
  } catch (e) {
    console.warn('⚠️  ECDH preload failed, fallback on demand:', e);
  }
}

上传时直接用:

async function deriveSessionKey(serverPublicKeyJwk) {
  const serverPubKey = await crypto.subtle.importKey(
    'jwk',
    serverPublicKeyJwk,
    { name: 'ECDH', namedCurve: 'P-256' },
    true,
    []
  );

  // 复用预生成的私钥,不再 generateKey
  const sharedSecret = await crypto.subtle.deriveKey(
    { name: 'ECDH', public: serverPubKey },
    window.__ecdh_keypair.privateKey, // ← 就是这里!复用
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );

  return sharedSecret;
}

这里注意我踩过好几次坑:私钥必须保持 alive(不能被 GC),所以得挂到全局对象上;另外 Safari 对 deriveKey 的输入参数校验极严,publicKey 必须是用 importKey 导入的,不能直接传 JWK 对象,否则报错不提示具体原因,折腾了半天才发现。

性能数据对比

实测环境:iPhone 13(iOS 17.5)、Chrome 126(Mac M1)、网络模拟 3G(Fast 3G)。同一台设备,同一上传流程,三次取平均:

  • 优化前(每次 generateKey):4.8s ± 0.3s
  • 优化后(预生成 + 复用):820ms ± 40ms
  • 提升:≈ 83%(从近 5 秒降到不到 1 秒)

而且这个 820ms 里,有 300ms 是网络请求(获取服务端公钥),纯 JS 密钥协商环节压到了 500ms 内。iOS 上提升更明显,从 5.2s 降到 910ms。现在用户点上传,界面几乎无感知,进度条直接动起来,再也不用听客户说“你们的加密比上传还慢”了。

顺带一提,我们还加了降级兜底:如果预生成失败(比如低配安卓机内存不足),就 fallback 到 on-demand generateKey,并加个 loading 提示——虽然概率极低,但线上真遇到过两例,没崩,只是慢一点,用户可接受。

踩坑提醒:这三点一定注意

  • 别信文档里“generateKey 很快”的鬼话——它在低端移动设备上就是性能炸弹,尤其 P-384 或 secp256k1,P-256 是目前最稳的底线;
  • 私钥不能 export,但可以长期持有——只要不泄露,复用 N 次完全 OK,ECDH 本身设计就支持多次 derive;
  • 别在 React 组件里 useState 存 keyPair——组件卸载就丢了,得挂到 window 或用 WeakMap 关联生命周期(我们选前者,简单粗暴)。

以上是我的优化经验,有更好的方案欢迎交流

这个方案不是理论最优(比如 WebAssembly 实现的 ECDH 可能更快),但它是我在两周内上线、零回滚、客户没再投诉的实战解法。如果你试过 WebCrypto + WASM 混合加速,或者用过 SubtleCrypto 的 worker 分离方案,欢迎评论区甩链接,我真想看看怎么再压 100ms。这个技巧的拓展用法还有很多,比如结合 Web Workers 做后台协商、或用 Atomics.wait 控制并发密钥派生,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论