做国密改造这段时间,我把网上能搜到的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 1=C1C3C2(默认),0=C1C2C3 * @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加密结果不是定长的。密文由三部分组成——C1(64或65字节的点坐标)、C3(32字节的SM3哈希值)、C2(与明文等长的密文流)。明文越长,密文越长。如果后端接口文档写“密文字段长度固定”,那一定是你对接的姿势不对。
3.4 签名验签轮子:doSignature/doVerifySignature
签名验签在国密改造里同样高频,接口防篡改、报文鉴权都会用到。同样封装好:
const sm2 = require('sm-crypto').sm2 /** * SM2签名(默认返回64字节的r+s拼接hex) * @param {string} msg 待签名字符串 * @param {string} privateKey 私钥hex串 * @param {Object} options 可选,例如 { hash: true } 表示先做SM3再签 * @returns {string} 签名hex(64字节) */ 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 = 'timestamp=1699999999999&body={"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字节的r+s拼接hex,还是ASN.1 DER编码hex,二选一,不能混。
建议在项目里建一个SM2_SPEC.md,把这些约定写成文档,后端同学照着文档做,比反复口头沟通高效得多。
4. 联调时最容易爆雷的四个细节
代码能跑通只是第一步,真正的考验全在前后端联调。这里把最容易爆雷的四个细节单独拎出来,每一个我都踩过,写出来帮你省掉排查时间。
4.1 C1C3C2和C1C2C3:cipherMode选错后端就解不出来
SM2的密文由C1、C2、C3三段拼接而成,国密标准(GB/T 32918.4)推荐顺序是C1C3C2,但老一些的文档、部分厂商实现用的是C1C2C3。这两种顺序都不影响算法本身的安全性,但前后端必须一致。
sm-crypto的doEncrypt(msg, publicKey, cipherMode)中,cipherMode = 1表示C1C3C2,cipherMode = 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字节签名hex(r+s拼接)转换为DER编码hex * @param {string} rsHex 128字符的r+s拼接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字节的r+s,才能交给doVerifySignature。如果你用的库比较新,也建议先查一下文档,有的版本已经内置了DER转换,只是参数名藏得比较深。
这里也建议在前后端约定阶段就直接锁定“统一用64字节hex,不改”,这样最省心。遇到老系统实在改不了,再写转换函数兜底。
4.4 字符集问题:中文内容和emoji必须显式约定UTF-8
SM2加密和签名面向的都是字节串。前端一句话“加密用户昵称”,后端解密出来是乱码,这个问题的根源几乎都是字符编码不一致。
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直接复用到压测脚本里,需要批量生成密钥对或构造假报文时,这几个方法能省下大量重复劳动。国密改造这条路,前端只是其中一环,但这一环踩的坑,足够写好几篇排错笔记了。