做Web端到端加密密钥交换永远是第一道门槛。我最早碰SJCL是在一个即时通讯Demo项目里要给两个浏览器窗口做安全的端到端通信后端只做中转、不碰消息内容。找了一圈前端加密库最终锁定了SJCL配合ECDH椭圆曲线密钥交换把整套流程跑通。这篇文章就把完整的方案、原理、代码和踩过的坑一次讲清楚适合正在做Web加密、对密码学感兴趣、或者需要在纯前端实现密钥协商的开发者参考。SJCLStanford JavaScript Crypto Library是一套纯JavaScript实现的密码学工具库支持AES、RSA、椭圆曲线、密钥派生等常用能力。和浏览器自带的Web Crypto API相比SJCL的API更贴近“教科书式密码学”你可以直接拿到椭圆曲线上的点坐标、自己拼接比特串、亲手执行标量乘法。对于想真正搞懂ECDH而不是只调黑盒接口的人来说这种透明性非常珍贵。1. 为什么选SJCL做ECDH本次方案选型的来龙去脉1.1 SJCL是什么它解决了什么问题SJCL最早由斯坦福计算机安全实验室发起目标很明确在浏览器环境里用纯JavaScript实现一套完整的密码学工具集不依赖后端、不依赖原生插件。它提供对称加密AES、Twofish等、非对称加密RSA、椭圆曲线、哈希SHA-1/SHA-256、随机数生成、密码学安全的密钥派生等能力全部打包在一个JS文件里浏览器直接引用就能用。在我那个即时通讯Demo里需求非常具体两个浏览器窗口之间建立端到端加密通信服务端只转发消息不能解密。这意味着密钥协商必须发生在两端之间且服务端即使拿到全部流量也推导不出会话密钥。ECDH天然适合这个场景而SJCL则是最顺手的前端实现载体。SJCL最适合的人群有两类一类是做教学、做Demo、做原型验证的开发者需要快速在浏览器里跑通加密流程另一类是需要在受限环境比如老版本WebView、离线环境、不允许依赖原生模块的浏览器插件里做加密的开发者。它的同步API让代码写起来像在写命令行工具没有Promise回调嵌套的烦恼调试时也能直接打印密钥、坐标、哈希结果非常直观。1.2 为什么不直接用原生Web Crypto API很多朋友看到这里会问浏览器早就有Web Crypto API了原生支持ECDH为什么还要用SJCL这个问题问得非常好。我做了下面这个对比维度SJCLWeb Crypto APIAPI风格同步、直接返回结果异步、Promise风格密钥可视化可以导出点坐标、密钥比特串密钥被封装成CryptoKey对象默认不可导出环境兼容老浏览器、WebView、Node都能跑需要现代浏览器环境可移植性同一套代码可在多端复用浏览器和Node环境差异较大教学理解曲线、点、坐标全部可见黑盒接口适合生产不适合学习维护状态更新慢但API稳定标准持续演化特性逐步增加Web Crypto API当然更适合生产环境它有硬件加速、有强健的随机源、有标准的密钥管理模型。但它有一个致命缺点CryptoKey对象把密钥的原始比特串锁在内部你想做自定义KDF密钥派生函数、想打印公钥指纹、想对密钥做二次加工都异常麻烦。我在实际项目里的选择是原型验证、教学演示、协议测试用SJCL因为可以看得见摸得着一旦需求稳定、面向现代浏览器再切换到Web Crypto API做性能优化。这篇文章讲的方案以SJCL为主线但原理和流程完全适用于Web Crypto API理解了前者后者几乎不需要额外学习成本。1.3 哪些场景更适合SJCL这套方案总结下来下面几类场景我最推荐直接用SJCL一、纯前端加密教学与实验。课程设计、技术分享、内部培训让学生亲手生成密钥对、交换公钥、算出共享点比直接调封装好的接口更有价值。二、自定义协议验证。如果你的协议要求自己定义公钥格式、自己设计密钥派生流程、自己加入身份认证上下文SJCL这种“零件外露”的库改起来非常顺手。三、老系统或嵌入式WebView。一些App内嵌的旧版WebView不支持完整的Web Crypto APISJCL作为纯JS库可以兜底。但也有一个必须说清楚的边界如果你在做生产级的大型项目比如商业聊天软件、网盘加密我建议把SJCL作为参考实现最终落地到Web Crypto API或原生客户端。密码学最忌讳自己造轮子SJCL本身是轮子但在它之上设计自定义协议时一定要清楚自己在做什么。2. 先搞懂ECDH在做什么原理层面的关键点2.1 椭圆曲线与离散对数问题ECDH的全称是Elliptic Curve Diffie-Hellman核心数学对象是椭圆曲线。它有一个形如 y² x³ ax b 的方程方程上的所有点构成一个群群上定义了一种“加法”运算。两个点相加得到曲线上的另一个点同一个点反复自加就相当于做乘法。公钥和私钥的关系其实就一句话私钥是一个随机大整数 k公钥是基点 G 自己“加”自己 k 次的结果记作 K kG。从 G 和 K 反推 k在椭圆曲线离散对数问题ECDLP的假设下是计算上不可行的。当前已知最快的通用攻击方法也是指数级的所以只要曲线参数选得好、比特长度足够这个方向基本没有捷径。用一个生活化的类比帮助理解设想一个巨大的钟表盘指针每走一格是固定的弧长。私钥是“指针走了多少格”公钥是“指针最后停在哪一格”。知道起始刻度基点G和最终刻度公钥K你不知道走了多少格因为格数大到无法穷举而且每一格的移动没有规律可反推。2.2 ECDH握手过程拆解ECDH的核心目标是双方各自持有一对公私钥交换公钥后分别计算出一个相同的共享密钥而窃听者即使抓到全部交换内容也算不出这个密钥。具体流程用Alice和Bob来做例子双方约定同一条椭圆曲线和同一个基点 G。这一步非常关键曲线不一致后面一切都白费。Alice生成随机私钥 a计算公钥 A aG。Bob生成随机私钥 b计算公钥 B bG。Alice把 A 发给BobBob把 B 发给Alice。Alice用自己的私钥 a 乘Bob的公钥 B得到共享点 S aB abG。Bob用自己的私钥 b 乘Alice的公钥 A得到共享点 S bA baG。椭圆曲线群上的乘法满足交换律和结合律所以 abG 和 baG 落在同一个点上。这个点的 x 坐标或者整个点就是双方共同的秘密。整个过程只有 A 和 B 在网络上传输窃听者拿到的只是两个公钥无法从中恢复共享点。我再做一个更生活化的类比。假设 Alice 和 Bob 要调配一种只有他们俩能配出的凝胶色每个人有自己的秘密颜料私钥同时把混入秘密颜料的半成品公开公钥。Alice拿自己的秘密颜料混合Bob的公开半成品Bob拿自己的秘密颜料混合Alice的公开半成品最终得到完全相同的凝胶色。旁观者拿到两个公开半成品但缺少任何一种秘密颜料拼不出凝胶色。2.3 公钥泄露后为什么仍然安全这里稍作深入。攻击者能看到的信息包括曲线参数、基点 G、Alice的公钥 A、Bob的公钥 B。他要破解密钥只能尝试从 A aG 或 B bG 中恢复私钥 a 或 b这就掉进了ECDLP的计算难题。安全强度取决于曲线比特长度的一半左右比如192位椭圆曲线大约提供96位安全强度256位曲线大约提供128位安全强度。这意味着即使攻击者拥有超大规模并行计算资源也要耗费天文数字级的计算时间才能破解。但这只是数学层面的安全协议层面的风险——尤其是中间人攻击——依然存在后面第5章会详细讲。有一件事必须从一开始就点明公钥交换本身不包含身份认证。Alice收到一个自称是Bob的公钥她无法从数学上判断这个公钥到底来自Bob还是攻击者。ECDH只管“计算共享密钥”不管“确认对方身份”这两个问题必须分开解决。3. SJCL实操从密钥生成到共享密钥计算3.1 环境准备与引入SJCLSJCL的获取方式很简单。浏览器环境可以直接用CDN引入或者下载 sjcl.js 放到项目目录里然后用普通script标签引入。Node环境也不复杂npm install sjcl 之后 require 即可。版本这里务必锁定一个我建议固定使用 1.0.8 或更新的稳定版本。SJCL的API虽然稳定但不同版本在椭圆曲线支持、随机数生成策略上略有差异版本漂移会造成跨端计算不一致的诡异问题。我自己的项目里直接锁了版本号避免团队其他成员升级依赖后导致密钥对不上。引入完成后你可以打印一下全局对象确认console.log(sjcl); // 期望看到 aes、ecc、hash、codec、mode、random 等子对象如果打印结果是 undefined说明文件没有正确加载此时检查路径和script标签顺序。3.2 生成ECDH密钥对SJCL没有单独命名一个“ECDH密钥生成器”实际使用的是椭圆曲线ElGamal密钥对。这在SJCL的架构里属于同一套椭圆曲线实现密钥结构完全一致也可以直接用于ECDH计算。生成密钥对的代码非常简洁// 第一个参数曲线名称或比特长度第二个参数paranoia随机源严格程度0~10 const keyPair sjcl.ecc.elGamal.generateKeys(192, 3); const mySecret keyPair.sec; // 私钥对象 const myPublic keyPair.pub; // 公钥对象关于曲线的选择SJCL内置了多条标准命名曲线例如 c192、c224、c256、c384、c521。192在这里指NIST P-192曲线提供约96位安全强度。做Demo和教学完全够用但如果是面向正式业务的加密通信我建议直接用 c256 或更高。虽然曲线比特数增加会产生一定性能开销但现代浏览器的JavaScript引擎处理椭圆曲线点乘已经很快256位曲线在普通PC上也就几毫秒级别不值得为此牺牲安全余量。paranoia参数是很多人容易忽略的细节。它表示对随机数的安全要求强度取值0到10默认是6。SJCL内部会调用浏览器的 crypto.getRandomValues 或第三方随机源来生成种子paranoia越高越会严格检查随机源质量在必要时拒绝生成密钥。生产环境我建议用6或更高教学Demo用3可以加快速度。别为了性能把paranoia调成0那等于告诉攻击者你的随机数可能不随机。3.3 导出并序列化公钥双方需要交换公钥所以得把公钥对象转成可以放进网络请求的格式。SJCL的公钥对象里有一个 get() 方法返回曲线上的点对象点对象有 x 和 y 两个字段是bitArray类型。导出的代码const point myPublic.get(); const pubX sjcl.codec.hex.fromBits(point.x); const pubY sjcl.codec.hex.fromBits(point.y); const publicKeyJson JSON.stringify({ curve: c192, // 务必带上曲线名 x: pubX, y: pubY }); // 发送 publicKeyJson 给对方接收方拿到JSON之后需要还原成SJCL的点对象const keyData JSON.parse(publicKeyJson); const curve sjcl.ecc.curves[keyData.curve]; if (!curve) { throw new Error(不支持的曲线类型); } // 注意前缀 0x 不能省sjcl.codec.hex.toBits 要求十六进制字符串带 0x 前缀 const otherPoint new sjcl.ecc.point( curve, sjcl.codec.hex.toBits(0x keyData.x), sjcl.codec.hex.toBits(0x keyData.y) );这里有个非常容易踩的坑SJCL的 hex.toBits 默认要求输入以 0x 开头如果不加前缀解密出来的bitArray长度是错误的后续点乘结果会出现坐标错乱。我第一次写这段代码时忽略了前缀结果两端计算出的共享点x坐标对不上排查了半个多小时才发现是这里的问题。公钥传输格式上我建议至少包含 curve、x、y 三个字段。忽略curve字段是绝对的自杀行为如果对方用的是c256而你用了c192两个共享点在数学上没有任何关联双方会得到不同的密钥而且这种错误不通过日志根本看不出来。3.4 用对方公钥计算共享点拿到对方的公钥并还原成点对象之后就可以计算共享密钥了。这一步是ECDH的核心SJCL里通过私钥对象的 unk() 方法拿到私钥标量再调用 mult() 执行椭圆曲线点乘。// 假设已经拿到了对方公钥并构造了 otherPoint 点对象 // mySecret 是自己的私钥对象 const sharedPoint mySecret.unk().mult(otherPoint); // 取共享点的 x 坐标作为原始共享秘密 const sharedX sjcl.codec.hex.fromBits(sharedPoint.x); console.log(共享点x坐标 (示例, 不要用于生产), sharedX);这里 mult 执行的就是我们前面讲的步骤私钥和对方公钥做标量乘法得到共享点 S。因为椭圆曲线的数学性质对方用他自己的私钥乘你的公钥会得到一模一样的点共享点的 x 坐标也因此一致。需要提醒一个安全细节共享点是一个椭圆曲线上的点直接把 x 坐标当作会话密钥使用是不推荐的。这个问题的根源在于曲线上的点在坐标上并不是均匀分布的x坐标的某些比特可能泄露曲线结构信息直接当AES密钥用可能降低实际安全强度所以在进入第4章的对称加密之前必须经过一次密钥派生。另外如果对方发了恶意构造的点比如不在曲线上、或者处于低阶子群直接做点乘可能泄露私钥信息。SJCL的标准点对象构造会做基础校验但一个完整的生产实现应当做曲线有效性验证。虽然这个话题超出这篇文章的范围但任何认真做ECDH工程的人都不该忽视它。4. 从共享秘密到可用密钥派生与对称加密4.1 为什么要做密钥派生很多人第一次实现ECDH时会直接把共享点的x坐标hex字符串当作密钥传给AES。我见过不少Demo代码这么干理论上能跑通但安全隐患很明显。第一个问题是长度不匹配。AES-128需要16字节密钥AES-256需要32字节。一个192位曲线的x坐标只有24字节如果直接截取或补齐不但浪费安全强度还会在两端产生不一致的风险。第二个问题是结构相关。椭圆曲线点的x坐标在有限域上分布并不完全均匀它具有一定数学结构直接作为密钥可能被某些侧信道或代数攻击利用。第三个问题是缺少上下文隔离。如果不做派生同一对公私钥在多个会话中会生成同一个共享密钥无法实现“每次会话一把钥匙”的前向安全设计。正确的做法是用一个密钥派生函数KDF把共享点的x坐标转换成均匀分布、长度固定的会话密钥。最直接的选择是SHA-256哈希也可以使用HKDF。SJCL本身没有内置HKDF但我们可以用一个简单的SHA-256方案安全级别足够大多数场景。4.2 定义自己的密钥派生函数我分享一个在项目里实测可行的简单派生方案把共享点x坐标、双方公钥、会话上下文拼接起来再做SHA-256最终得到32字节的密钥。加入双方公钥和会话ID的意图是把密钥“绑定”到参与者和会话上防止跨会话混淆或重放。// 派生会话密钥输入是共享点x坐标hex、自己公钥hex、对方公钥hex、会话ID function deriveSessionKey(sharedXHex, myPubHex, otherPubHex, sessionId) { // 拼接上下文顺序固定避免歧义 const material sharedXHex | myPubHex | otherPubHex | sessionId; const bits sjcl.hash.sha256.hash(material); // 返回32字节的bitArray可以直接作为AES-256密钥 return bits; }这里有一个原则拼接时字段顺序必须固定。如果A端拼接顺序是“我你”B端拼接顺序是“你我”哪怕原始字节完全一样派生出来的密钥也会不同。我建议在代码里显式定义一个拼接模板并且在注释里写明防止后续维护时不小心调整顺序。SHA-256在这里充当的是一个简化版的KDF。严格意义上的HKDF还包含“提取-扩展”两步在密钥熵较低时需要加盐处理。但ECDH共享点x坐标的熵已经接近曲线安全强度SHA-256直接压缩是常见且可接受的简化。如果项目要求更高可以用Web Crypto API的HKDF实现替换网络上有大量现成封装。4.3 用AES-256-GCM加密实际业务数据得到32字节会话密钥后就可以用对称加密来加密业务消息。对称加密选型上我强烈推荐AES-GCM它同时提供机密性和完整性校验能检测密文是否被篡改算法本身是现代密码学里的标准选择。SJCL使用AES-GCM的代码如下function encryptMessage(sessionKeyBits, plaintext, iv) { // plaintext: string // iv: bitArray长度128位4个word const ciphertextBits sjcl.mode.gcm.encrypt( sessionKeyBits, sjcl.codec.utf8String.toBits(plaintext), iv ); return sjcl.codec.hex.fromBits(ciphertextBits); } function decryptMessage(sessionKeyBits, ciphertextHex, iv) { const plaintextBits sjcl.mode.gcm.decrypt( sessionKeyBits, sjcl.codec.hex.toBits(ciphertextHex), iv ); return sjcl.codec.utf8String.fromBits(plaintextBits); }GCM模式要求IV初始化向量每次都不同。如果同一把会话密钥下使用相同的IV加密多条消息攻击者可以通过异或操作还原明文这是GCM模式的一个经典坑。最稳妥的做法是每次加密时用安全随机数生成IV并把IV拼在密文前面一起传输。// 生成128位IV const iv sjcl.random.randomWords(4, 10); // 加密 const cipherHex encryptMessage(sessionKeyBits, hello ecdh, iv); // 传输时携带 ivHex 和 cipherHex const payload JSON.stringify({ iv: sjcl.codec.hex.fromBits(iv), ct: cipherHex });关于keyBitsSJCL的 sha256.hash 返回的bitArray正好是32字节即256位满足AES-256的要求。如果你是AES-128或AES-192的需求可以截断bitArray但我不建议降级AES-256在现代环境下性能开销并不明显直接用满32字节更省事。4.4 完整流程串起来把上面的代码串成一个完整的握手加密流程。假设Alice和Bob在同一个页面里模拟两个用户角色以下是核心逻辑的整合示例// Alice端 const alicePair sjcl.ecc.elGamal.generateKeys(c192, 3); const alicePub serializePublicKey(alicePair.pub); // 发送 alicePub 给 Bob并收到 bobPub const aliceSharedPoint alicePair.sec.unk().mult(parsePublicKey(bobPub)); const aliceSharedHex sjcl.codec.hex.fromBits(aliceSharedPoint.x); const aliceKey deriveSessionKey(aliceSharedHex, alicePub, bobPub, session-001); // Bob端 const bobPair sjcl.ecc.elGamal.generateKeys(c192, 3); const bobPub serializePublicKey(bobPair.pub); const bobSharedPoint bobPair.sec.unk().mult(parsePublicKey(alicePub)); const bobSharedHex sjcl.codec.hex.fromBits(bobSharedPoint.x); const bobKey deriveSessionKey(bobSharedHex, alicePub, bobPub, session-001); // 此时 aliceKey 和 bobKey 完全一致 // Alice向Bob发送加密消息 const iv sjcl.random.randomWords(4, 10); const cipherHex encryptMessage(aliceKey, 即使服务端拿到流量也无法解密, iv); const payload JSON.stringify({ iv: sjcl.codec.hex.fromBits(iv), ct: cipherHex }); // Bob解密 const payloadObj JSON.parse(payload); const plaintext decryptMessage( bobKey, payloadObj.ct, sjcl.codec.hex.toBits(0x payloadObj.iv) ); console.log(plaintext); // 即使服务端拿到流量也无法解密到这里一个完整的ECDH密钥交换加加密通信闭环已经跑通。这个闭环意味着两端在没有预共享密钥的前提下通过不安全的信道协商出了一个只有双方知道的会话密钥之后的所有业务消息都通过这个密钥加密传输。5. 常见问题排查与安全陷阱实录5.1 两端的共享点对不上这是所有人第一次实现ECDH必踩的坑。现象是Alice算出的共享点x坐标和Bob算出的完全不一致甚至长度都不同。根据我的排查经验九成原因是编码环节出错而不是密码学问题。最常见的几个一、曲线不一致。一端用c192另一端用c256算出的点完全不同。检查JSON里的curve字段和 sjcl.ecc.curves 的映射。二、hex前缀缺失。sjcl.codec.hex.toBits 要求字符串带 0x不加前缀会导致bitArray长度错误这是SJCL最容易忽略的细节。三、公钥序列化时x和y顺序颠倒。JSON字段顺序不影响解析但赋值时的顺序错了比如把 y 当 x 传进去点对象就不是原来的点。四、字符串编码问题。公钥json经过URL传输时可能被转义或截断导致还原出的十六进制和原值不一样。排查时我建议先在两端的代码里分别打印原始pubX和pubY再打印还原后的bitArray。只要十六进制字符串完全一致点乘结果必然一致如果十六进制字符串不一致问题一定出在序列化或传输环节而不是SJCL本身。5.2 中间人攻击与身份认证ECDH本身对中间人攻击是没有免疫力的。攻击者可以同时冒充Alice和Bob分别与双方完成密钥交换然后中转所有消息。这种情况在密码学上有一个专门术语中间人攻击而且是ECDH协议层面绕不开的短板。解决方式是在密钥交换协议之外增加身份认证机制。实践中最简单也最可靠的方式是公钥指纹比对对双方公钥的序列化字符串做SHA-256哈希得到一串指纹然后通过第三方可信渠道语音电话、线下见面、另一个已经认证的通信通道核对指纹是否一致。指纹比对的代码很简单function fingerprint(publicKeyJson) { const normalized JSON.stringify(JSON.parse(publicKeyJson)); // 标准化消除键顺序差异 const hash sjcl.hash.sha256.hash(normalized); return sjcl.codec.hex.fromBits(hash).slice(0, 16); // 取前16位hex约64位指纹 }在Demo项目里可以把指纹显示在界面上由双方口头核对。生产项目中更常见的做法是把公钥交给服务端用签名密钥签名客户端通过证书链验证签名。但无论哪种方案核心思路都一样在信任公钥来源之前不要用这个公钥做任何加密传输。5.3 随机数质量风险SJCL依赖浏览器的随机源生成密钥如果随机源质量差私钥的可预测性就会让整个加密体系崩塌。paranoia参数在这里扮演了重要角色。我见过有人把paranoia调成0想缩短页面加载时间结果密钥生成速度确实快了但安全性大打折扣。特别是做教学项目时这种做法很容易给学生养成坏习惯。我的建议是教学Demo用3到6生产使用必须6以上。SJCL在paranoia较高时如果无法获取足够熵会抛异常而不是默默生成弱密钥这种“宁可不生成也不降级”的设计值得尊重。另外不要在服务器端用SJCL的随机数生成会话密钥。服务端环境往往有更好的随机源和更成熟的密钥管理方案SJCL的随机数模块主要针对浏览器环境设计。5.4 排查速查表现象可能原因解决办法两端共享点x坐标不一致曲线不同、hex缺0x前缀、序列化字段颠倒先比对原始hex再逐项检查编码报错 Cannot read properties of null公钥JSON解析失败或curve类型不存在打印接收到的JSON检查curve名称解密时报tag mismatch密文被篡改、IV不一致、密钥不对确认GCM模式和密钥派生参数完全一致密钥生成特别慢paranoia设置过高随机源不稳定从6开始确认浏览器环境正常同一公钥在不同端行为不一致SJCL版本不一致锁定版本号统一依赖另外提醒一句SJCL的老版本和较新版本在椭圆曲线库上有个别API差异如果你用npm安装的最新版而博客里参考的是老版写法遇到奇怪的报错时先看看版本。我自己的习惯是把版本号写死在package.json里并且在前端全局带上版本检测逻辑多端不一致时显式报错。5.5 一个额外的测试技巧密钥交换完成后建议先做一个“密钥一致性验证”再开始正式传输业务消息。方法很简单双方约定一个固定字符串比如“handshake-ok”Alice用协商出的密钥加密后发给BobBob解密后验证内容是否匹配如果匹配再回复确认。如果验证失败立即中止通信并排查原因而不是让后续消息全部发往一个错误密钥的通道。这个技巧我在项目里一直沿用它可以避免大量无效调试。否则你可能会看到一条消息被加密成完全无法解密的乱码还要花费数小时去定位是密钥问题、编码问题还是传输问题。最后再分享一点个人经验用SJCL做ECDH本质上是把密码学原理“亲手实现了一遍”。这套流程跑通之后你再去用Web Crypto API或封装好的SDK会有一种豁然开朗的感觉——因为你知道每一层黑盒内部到底做了什么。如果你正在做端到端加密别只停留在调库层面花一个下午把这个过程完整过一遍收获远超预期。
阅读完成 · 觉得有帮助?