做Web端到端加密,密钥交换永远是第一道门槛。我最早碰SJCL,是在一个即时通讯Demo项目里要给两个浏览器窗口做安全的端到端通信,后端只做中转、不碰消息内容。找了一圈前端加密库,最终锁定了SJCL,配合ECDH椭圆曲线密钥交换把整套流程跑通。这篇文章就把完整的方案、原理、代码和踩过的坑一次讲清楚,适合正在做Web加密、对密码学感兴趣、或者需要在纯前端实现密钥协商的开发者参考。
SJCL(Stanford 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?
这个问题问得非常好。我做了下面这个对比:
| 维度 | SJCL | Web Crypto API |
|---|---|---|
| API风格 | 同步、直接返回结果 | 异步、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 API,SJCL作为纯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 发给Bob,Bob把 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 环境准备与引入SJCL
SJCL的获取方式很简单。浏览器环境可以直接用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 });关于keyBits,SJCL的 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用协商出的密钥加密后发给Bob,Bob解密后验证内容是否匹配,如果匹配再回复确认。如果验证失败,立即中止通信并排查原因,而不是让后续消息全部发往一个错误密钥的通道。
这个技巧我在项目里一直沿用,它可以避免大量无效调试。否则你可能会看到一条消息被加密成完全无法解密的乱码,还要花费数小时去定位是密钥问题、编码问题还是传输问题。
最后再分享一点个人经验:用SJCL做ECDH,本质上是把密码学原理“亲手实现了一遍”。这套流程跑通之后,你再去用Web Crypto API或封装好的SDK,会有一种豁然开朗的感觉——因为你知道每一层黑盒内部到底做了什么。如果你正在做端到端加密,别只停留在调库层面,花一个下午把这个过程完整过一遍,收获远超预期。