ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SM2 JavaScript实现实战:密钥格式、密文顺序与签名编码避坑指南

SM2 JavaScript实现实战:密钥格式、密文顺序与签名编码避坑指南 做国密改造这段时间我把网上能搜到的SM2 JS实现几乎翻了个遍一个很扎心的事实是大多数“可用”代码要么依赖一个已经没人维护的旧库要么只给教学片段密钥结构、密文格式、签名编码一深入就露馅。真正放到生产环境能扛住前后端联调的少之又少。所以这篇我把这段时间沉淀下来的一个真实可用的SM2 JavaScript实现方案完整拆开讲不只给代码还把密钥格式、密文顺序、签名编码这些最容易翻车的底层层层剥开顺便附带完整排错记录。适合正在做国密合规改造、数据库国密测试、或者需要在前端完成SM2加解密和数字签名的同学参考。1. 先认清需求JS端做SM2你到底要解决什么问题不少朋友一上来就搜“SM2 JS实现”其实业务场景根本没想清楚。我接触过的项目里前端引入国密算法通常有几类动机但并不是所有场景都适合把私钥放到浏览器里这个边界必须一开始就划明白。1.1 前端做国密的典型场景最常见的场景是登录密码或敏感字段的前端加密。用户输入的口令、身份证号、手机号等字段在HTTPS已经覆盖的情况下仍然会有安全合规要求指定必须使用国密算法对传输内容做二次加密。此时前端持有SM2公钥用公钥加密后传给后端后端用私钥解密这种模式下私钥从不离开服务端模型是安全的。第二个高频场景是数字签名。比如接口防篡改、请求参数签名前端用私钥对请求体做签名后端验签。这个场景要求私钥分发到前端安全等级天然低一档但如果你的业务本来就在半可信环境比如企业内部系统、嵌入到客户现场的网关配合安全键盘、内存加密等方案仍然可以落地。第三个场景是国密浏览器插件和数据库国密测试的配套联调。最近很多团队在做OceanBase、TDSQL等数据库的国密改造应用侧可能需要临时用JS工具生成SM2密钥对、构造加密报文去验证链路是否通了。这种调试性质的工具对代码可用性的要求比性能更高。1.2 不适合交给前端的加密操作必须泼一盆冷水任何要求“绝对安全”且私钥不能泄露的场景都不适合把私钥放进前端JS代码里。浏览器环境对用户是透明的只要打开DevTools就能翻到所有加载的脚本Webpack打包后的代码也可以被还原密钥硬编码在前端等于公开。所以如果需求描述是“前端实现SM2加解密密钥写死在JS里”你要做的是先和负责人确认威胁模型而不是直接写代码。另外大批量数据的加密也不建议走前端SM2。SM2是椭圆曲线公钥算法加密时要做点乘和KDF性能比对称加密低一两个数量级。真要加密体积较大的业务数据通常是前端用SM4对称加密再用SM2加密SM4的密钥也就是“SM2信封”方案。这个后面在联调细节里会展开。1.3 非对称模型中的密钥归属SM2和RSA一样是非对称加密核心是一对密钥公钥用于加密和验签私钥用于解密和签名。椭圆曲线的数学基础是基于椭圆曲线离散对数问题给定点P和私钥d计算公钥P dG很容易但从公钥反推私钥在计算上不可行这是整个算法的安全根基。用生活化类比公钥相当于一把只有锁的挂锁任何人都可以拿到它、把消息锁进箱子里加密但只有持有钥匙的一方私钥才能打开。JS端如果只做加密只需要挂锁也就是公钥如果要做签名就需要钥匙也就是私钥。常见的错误是把公钥和私钥当成普通字符串随便传忽略了它们本质上是椭圆曲线上的点和大整数。公钥通常写作04 x坐标 y坐标的十六进制串04表示未压缩点x和y各占32字节私钥则是一个256位的大整数也以十六进制表示通常64个字符。理解了这一点后面看代码才不会觉得格式怪异。2. 库的选型为什么我把手写椭圆曲线的冲动按了回去SM2的JS实现其实有不少选择但口碑差距非常大。我最早也动过“自己写一个”的念头——毕竟SM2标准文档是公开的椭圆曲线算法也有成熟公式。但冷静看了一圈这个念头基本等于“为了喝杯牛奶打算养头牛”我劝你也按一按这个冲动。2.1 三个主流方案的横向对比方案维护状态包体积特点适合场景sm-crypto原生JS维护较活跃社区使用面广约几十KB纯JS公私钥生成、加解密、签名验签齐全支持浏览器和Node绝大多数前端国密需求基于WebAssembly的国密库依赖具体封装方更大性能好但需要额外加载wasm对性能有硬指标的场景自己手写椭圆曲线运算自己维护不定学习价值高踩坑成本极高不建议生产使用实际上sm-crypto是现阶段前端做SM2绕不开的一个库原因很简单它把标准算法封装成了几个直白的API参数支持十六进制字符串返回结果也是字符串拿来就能接业务。这个库我用了大半年前后端联调、加解密、签名验签都跑过稳定性没问题。本文后面的代码都以它为基础这不是广告是踩完坑之后最省事的方案。2.2 手写实现为什么容易翻车如果你还是很想自己写椭圆曲线运算我先把几个注定会踩的坑摆出来第一JavaScript的Number精度问题。SM2基于256位大数运算而JS的Number类型安全整数范围只有2^53左右直接用Number做椭圆曲线点乘、模逆中间结果一旦溢出算出来的点就是错的而且这种错误非常隐蔽加密偶尔成功、偶尔失败极难排查。解决办法是用BigInt但BigInt引入之后取模、求逆、点加、倍点这些操作全要重新实现代码量立刻膨胀。第二模逆和点运算的工程细节。椭圆曲线上的点加、倍点、标量乘法涉及扩展欧几里得算法求模逆、模幂运算、Jacobian坐标系与仿射坐标系的转换任何一个细节写错结果都会偏离正确值。网上能找到的SM2手写实现很多连点是否在曲线上的校验都跳过了生成的公钥根本不在曲线上。第三测试向量缺失。GB/T 32918标准文档里是有官方测试向量的但很多手写实现没有用标准向量验证过自己写的测试用例又恰好绕过错误分支最后“看起来能用”一换数据就崩。所以结论很直接生产环境老老实实用成熟的库真想学习椭圆曲线数学另开一个项目慢慢玩。3. 真实可用的核心实现密钥对、加解密、签名验签下面进入正题。我直接给出一套可以在浏览器和Node环境双端运行的实现基于sm-crypto库并封装了一层更符合业务习惯的工具类。这个工具类我在真实项目里跑了大半年包括登录加密、接口签名、数据库国密联调测试都靠它。3.1 安装依赖npm install sm-crypto如果你的项目是浏览器直引也可以通过script标签引入打包后的UMD文件具体路径在node_modules/sm-crypto/dist下这里不展开。3.2 密钥对生成与公钥格式说明const sm2 require(sm-crypto).sm2 // 生成密钥对 const keypair sm2.generateKeyPairHex() console.log(私钥, keypair.privateKey) // 输出示例d25b2b7e0f1b8d0d9d3e5f7a6c8b9a1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7 console.log(公钥, keypair.publicKey) // 输出示例04 x坐标 y坐标共130个十六进制字符这里必须说明一个细节generateKeyPairHex返回的公钥是未压缩点格式也就是以04开头后面紧跟64字节的x坐标和64字节的y坐标一共130个十六进制字符私钥则是64个十六进制字符。有的后端框架尤其Java的BouncyCastle会要求把公钥字符串转成X509EncodedKeySpec或ECPublicKey对象这时候前端传过去的就是这串130字符的hex。也有的后端为了省流量会去掉04前缀只传128字符的“裸坐标”但这一般需要双方约定不要擅自裁剪否则后端解码会直接报“Invalid point coordinates”。3.3 加密轮子doEncrypt/doDecrypt加解密是SM2使用频率最高的能力。下面是一个封装好的工具方法支持选择密文格式C1C3C2或C1C2C3这是很多联调事故的根源后面专门讲。const sm2 require(sm-crypto).sm2 /** * SM2加密 * param {string} msg 明文内容 * param {string} publicKey 公钥hex串 * param {number} cipherMode 1C1C3C2默认0C1C2C3 * returns {string} 密文hex串 */ function sm2Encrypt(msg, publicKey, cipherMode 1) { // 库内部默认输入按UTF-8处理中文等字符不需要手动encodeURIComponent return sm2.doEncrypt(msg, publicKey, cipherMode) } /** * SM2解密 * param {string} cipherText 密文hex串 * param {string} privateKey 私钥hex串 * param {number} cipherMode 必须与加密时保持一致 * returns {string} 明文内容 */ function sm2Decrypt(cipherText, privateKey, cipherMode 1) { return sm2.doDecrypt(cipherText, privateKey, cipherMode) }使用方式很简单const publicKey 04xxxxxx... const privateKey d25b... // 加密 const encryptResult sm2Encrypt(你好国密世界, publicKey, 1) console.log(encryptResult) // 解密 const decryptResult sm2Decrypt(encryptResult, privateKey, 1) console.log(decryptResult) // 你好国密世界这里有个容易被忽略的点SM2加密结果不是定长的。密文由三部分组成——C164或65字节的点坐标、C332字节的SM3哈希值、C2与明文等长的密文流。明文越长密文越长。如果后端接口文档写“密文字段长度固定”那一定是你对接的姿势不对。3.4 签名验签轮子doSignature/doVerifySignature签名验签在国密改造里同样高频接口防篡改、报文鉴权都会用到。同样封装好const sm2 require(sm-crypto).sm2 /** * SM2签名默认返回64字节的rs拼接hex * param {string} msg 待签名字符串 * param {string} privateKey 私钥hex串 * param {Object} options 可选例如 { hash: true } 表示先做SM3再签 * returns {string} 签名hex64字节 */ function sm2Sign(msg, privateKey, options {}) { // 是否先对消息做SM3哈希通常根据后端要求来决定 const signOpts Object.assign({ hash: false }, options) return sm2.doSignature(msg, privateKey, signOpts) } /** * SM2验签 * param {string} msg 原始字符串 * param {string} signHex 签名hex串 * param {string} publicKey 公钥hex串 * param {Object} options 必须与签名时保持一致 * returns {boolean} 是否通过 */ function sm2Verify(msg, signHex, publicKey, options {}) { const verifyOpts Object.assign({ hash: false }, options) return sm2.doVerifySignature(msg, signHex, publicKey, verifyOpts) }使用const publicKey 04xxxxxx... const privateKey d25b... const msg timestamp1699999999999body{amount:100} const sign sm2Sign(msg, privateKey) console.log(签名, sign) const ok sm2Verify(msg, sign, publicKey) console.log(验签结果, ok) // true关于hash选项需要特别提醒SM2签名标准里通常要求先对消息做ZA 消息的SM3摘要再参与签名。sm-crypto的doSignature如果不传hash等价于对原始消息直接做SM2签名如果传{ hash: true }则内部会先做SM3再签名。后端如果用BouncyCastle默认的签名流程通常是后者。两边的哈希设定必须一致否则验签永远失败。3.5 前后端字段约定建议这段是我在联调中被坑出来的经验可以直接抄公钥字段统一约定为130字符的十六进制串以04开头前后端都不做裁剪。私钥字段只存在服务端配置或前端安全存储中不上送日志不打印明文。密文字段统一约定为小写十六进制串明确C1C3C2还是C1C2C3。签名字段统一约定为64字节的rs拼接hex还是ASN.1 DER编码hex二选一不能混。建议在项目里建一个SM2_SPEC.md把这些约定写成文档后端同学照着文档做比反复口头沟通高效得多。4. 联调时最容易爆雷的四个细节代码能跑通只是第一步真正的考验全在前后端联调。这里把最容易爆雷的四个细节单独拎出来每一个我都踩过写出来帮你省掉排查时间。4.1 C1C3C2和C1C2C3cipherMode选错后端就解不出来SM2的密文由C1、C2、C3三段拼接而成国密标准GB/T 32918.4推荐顺序是C1C3C2但老一些的文档、部分厂商实现用的是C1C2C3。这两种顺序都不影响算法本身的安全性但前后端必须一致。sm-crypto的doEncrypt(msg, publicKey, cipherMode)中cipherMode 1表示C1C3C2cipherMode 0表示C1C2C3。默认是1。联调时如果前端加密后端解不开优先看这一段// 后端如果报Invalid input, cipherText is incomplete // 先检查前端用的cipherMode和后端解析时的顺序是否一致 const encryptResult sm2Encrypt(测试报文, publicKey, 1) // C1C3C2如果你拿到的后端SDK只支持C1C2C3而前端框架默认C1C3C2有两个办法一是前端改成cipherMode 0二是写一个密文顺序转换函数把C1C3C2的字符串重排为C1C2C3。第二种方法可以更稳妥地兼容对端转换原理就是按字节切分三段再重拼/** * 将C1C3C2格式密文转换为C1C2C3 * param {string} cipherTextHex C1C3C2格式hex串 * returns {string} C1C2C3格式hex串 */ function cipherC1C3C2ToC1C2C3(cipherTextHex) { // C1部分04开头时长度为130个hex字符如果不带04则128个hex字符 const c1Len cipherTextHex.startsWith(04) ? 130 : 128 const c1 cipherTextHex.slice(0, c1Len) // C3部分固定SM3长度32字节 64个hex字符 const c3 cipherTextHex.slice(c1Len, c1Len 64) const c2 cipherTextHex.slice(c1Len 64) return c1 c2 c3 } /** * 将C1C2C3格式密文转换为C1C3C2 */ function cipherC1C2C3ToC1C3C2(cipherTextHex) { const c1Len cipherTextHex.startsWith(04) ? 130 : 128 const c1 cipherTextHex.slice(0, c1Len) const c2 cipherTextHex.slice(c1Len, cipherTextHex.length - 64) const c3 cipherTextHex.slice(cipherTextHex.length - 64) return c1 c3 c2 }这段代码建议直接放进工具类联调现场最缺的就是这种“救火队员”。4.2 密文被URL传输转义号和斜杠都别碰运气还有一个非常隐蔽的坑在传输环节。SM2密文是十六进制字符串本来只包含0-9a-f安全得很。但如果你或者后端图方便在传输前把密文做了一次Base64编码然后拼到URL查询参数里问题就来了——Base64编码包含、/、三个字符其中在URL解码时会被转换成空格/在某些框架里会被当作路径分隔符处理也可能被解析成键值对分隔符。前端发请求如果用了encodeURIComponent还好最怕的是手写URL拼接直接?cipher base64字符串后端收到的密文早就被改得七零八落。解密时要么报长度不对要么报“C1点不在曲线上”。我的建议是密文在前后端之间传输一律使用纯hex小写字符串不要转Base64。如果一定要Base64前端明确做encodeURIComponent(base64Str)后端明确做URLDecoder.decode并在文档里写清楚。这种低级错误排查起来特别浪费人生。4.3 签名格式64字节原始格式和DER编码不通用SM2签名的输出格式是让很多人摸不着头脑的地方。标准签名的数学结果是两个大整数r和s每个32字节。不同的语言和SDK输出格式不同前端sm-crypto默认输出r s的64字节hex拼串。Java BouncyCastle默认输出ASN.1 DER编码hex字符串会比64字节长通常70字节左右以30开头。部分SDK输出r | s但每个值固定补0到64位表现形式和前者相同。联调时如果后端Java验签一直失败而你确认哈希模式和公钥都没问题那么大概率是签名格式不匹配。前端需要做的是把64字节的r s拼接格式转换成DER编码格式/** * 将64字节签名hexrs拼接转换为DER编码hex * param {string} rsHex 128字符的rs拼接hex * returns {string} DER编码hex */ function rsToDer(rsHex) { const r rsHex.slice(0, 64) const s rsHex.slice(64) let rDer r.replace(/^0/, ) // 去掉前导零 if (parseInt(rDer.slice(0, 1), 16) 8) rDer 00 rDer let sDer s.replace(/^0/, ) if (parseInt(sDer.slice(0, 1), 16) 8) sDer 00 sDer const innerLen rDer.length / 2 sDer.length / 2 4 const seqLen innerLen 128 ? innerLen.toString(16).padStart(2, 0) : innerLen.toString(16) const rLen (rDer.length / 2).toString(16).padStart(2, 0) const sLen (sDer.length / 2).toString(16).padStart(2, 0) return 30 seqLen 02 rLen rDer 02 sLen sDer }反过来后端传了个DER格式给前端验签前端需要把DER还原成64字节的rs才能交给doVerifySignature。如果你用的库比较新也建议先查一下文档有的版本已经内置了DER转换只是参数名藏得比较深。这里也建议在前后端约定阶段就直接锁定“统一用64字节hex不改”这样最省心。遇到老系统实在改不了再写转换函数兜底。4.4 字符集问题中文内容和emoji必须显式约定UTF-8SM2加密和签名面向的都是字节串。前端一句话“加密用户昵称”后端解密出来是乱码这个问题的根源几乎都是字符编码不一致。sm-crypto的doEncrypt默认会把输入字符串按UTF-8编码再加密大多数现代后端框架也默认UTF-8一般不会出问题。但如果你遇到存量系统后端用的GBK或GB2312那就麻烦了——同一个“用户”二字UTF-8编码是3字节GBK是2字节字节不同解密出来的字节序列自然就无法还原成正确字符。签名同样受编码影响。前后端对同一消息签名时任何一端用了不同编码方式签名结果就不一致验签必然失败。解决方式很朴素但需要写在联调文档里“所有参与SM2加解密、签名的字符串一律UTF-8编码。”5. 一次真实排错后端一直报Invalid point coordinates的完整链路讲了这么多理论最后用一个我实际经历过的排错案例收尾。这个案例几乎浓缩了SM2联调的大部分坑场景是给一套数据库国密测试工具做前端加密后端Java服务一直报错。5.1 现象和第一步排查现象很直接前端调用加密接口后后端日志报Invalid point coordinates。这个错误从字面上看就是公钥或密文中的C1点坐标不在SM2椭圆曲线上或者坐标格式无法解析。我的第一反应不是去怀疑库而是怀疑公钥在传输过程中被改了。打开前后端日志对比前端打印的公钥和后端收到的公钥逐字符比对后发现完全一致公钥没问题。接着对比密文。前端加密之后console.log出来的密文是130多字符的hex串没毛病。但是后端日志里打出来的密文长度对不上前端encryptResult是194个字符这里测试明文是10字节加上C1的130个字符和C3的64个字符正好194后端收到却只有192。少了两个字符但肉眼几乎看不出来。5.2 定位过程少了两个字符说明不是完整密文被截断而是某个中间环节把04开头的C1点坐标给“优化”了。再往下查发现后端在解析前端传来的JSON时把密文先做了一次trim()。按理说hex字符串没有前后空格trim不会改变内容。但排查到这里我突然想到一个问题这个密文在传到后端之前经过了一个参数签名中间件。中间件在生成签名时会把请求体里的字段全部按字母序排序后拼接。排序本身没问题问题是它拼接时用了连接符而密文里恰好没有纯hex所以这个嫌疑暂时排除。继续看终于找到了元凶。前端请求的Content-Type是application/x-www-form-urlencoded前端把密文放进了表单体。浏览器在表单序列化时十六进制字符串本身只是0-9a-f不会转义但问题出在中间有个网关层它把请求体里的字符串统一用String.trim()处理了一遍。我们传入的密文之前在后端工具函数里被包了一层“为了日志好看”的格式化格式化的函数把长字符串按每64字符换行展示换行符\n被拼进了密文串尾部。前端的密文本体没带换行但网关在读取时自动补了一个空格再trim理论上trim能去掉空格和换行应该不会破坏hex本身。真正的问题出现在更隐蔽的地方网关层对请求体做了“智能修复”把04开头的C1点坐标当成了“非法起始字节”自动剥离了前导的04。这听起来很离谱但确实是一些做了协议修正的安全网关会干的事情。C1点坐标去掉04前缀之后坐标点格式从“未压缩点”变成了“裸坐标”长度少了2个字符后端按130个字符去解析就彻底乱了最终报出Invalid point coordinates。5.3 修复方案和二次验证定位到这个原因后修复方案反而简单绕开网关节点的“智能修复”或者在前端请求前显式声明Content-Type为application/json让网关不对body内容做启发式修正。具体改动如下// 之前 const formData new URLSearchParams() formData.append(cipher, encryptResult) fetch(/api/sm2/decrypt, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: formData.toString() }) // 之后 fetch(/api/sm2/decrypt, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ cipher: encryptResult }) })改完之后后端收到的密文长度恢复为194个字符Invalid point coordinates消失解密正常。这段排错全程看起来像是一场“绕圈”但它反映了一个非常现实的道理SM2联调报错很多时候不是算法本身的问题而是数据在传输链路里被某个你根本没想到的中间层动了手脚。遇到类似报错先做“逐环节对比”——把自己的输出、网关后的输出、后端收到的输入一层层打点对比长度、前缀、内容逐个查比盲目换库高效得多。6. 写在最后的工具封装建议这段算是我个人实操的体会。SM2用起来不难但要让它在项目里长期稳定建议不要到处require(sm-crypto)散着调而是统一封装成Sm2Util模块把下面几件事一次做齐统一封装密钥对生成、加密、解密、签名、验签方法。内置cipherMode参数默认C1C3C2同时提供密文顺序转换函数。内置签名格式转换函数rsToDer和derToRs方便和Java后端对接。所有输入输出统一为小写hex字符串方便日志排查。封装一个简单的自测方法生成密钥对加密再解密签名再验签全部通过再输出SM2 self-test ok。每次部署前端或者升级依赖后跑一遍能挡住90%的“库版本升级导致联调失败”问题。最近在做数据库国密测试的朋友也建议把Sm2Util直接复用到压测脚本里需要批量生成密钥对或构造假报文时这几个方法能省下大量重复劳动。国密改造这条路前端只是其中一环但这一环踩的坑足够写好几篇排错笔记了。
返回列表