1. 为什么 SM2 值得单独拎出来讲
先说个我常被问到的问题:现在 HTTPS、区块链、电子签名满大街都是 ECDSA、RSA、Ed25519,为什么还要花大力气去搞一个 SM2?尤其是这两年做政务、金融、能源、医疗信息化改造的时候,"必须用国密算法"这句话几乎成了硬性要求。其实说到底就两个字:合规。国内不少核心系统过等保、过密评,或者要跟 CA 体系做对接,SM2 都是绕不开的一环。而且它本身也不是“随便造一个曲线凑数”,我们做密码算法对比测试的时候,把 SM2 和 P-256 曲线 ECDSA 放在同样的硬件环境里跑过,性能几乎在同一量级,安全性设计上也没有明显短板。
如果你现在刚开始接触国密体系,建议先把整个图谱理清楚:对称加密用 SM4,哈希摘要用 SM3,非对称密钥与签名验签用 SM2。这三者经常组合出现,比如 TLS 国密套件里,握手过程用 SM2 做身份认证和密钥协商,会话数据用 SM4 加密,消息校验用 SM3。本文只把 SM2 这一块讲透,包括它的椭圆曲线原理、密钥生成、加密解密流程、签名验签细节,以及我在实际项目里踩过的那些坑。
适合看这篇文章的人有三类:一是正在做等保整改、密评改造的开发者;二是要在系统里接入国密证书链、做电子签章或数据加密的服务端工程师;三是对密码学感兴趣,想搞明白 SM2 和 ECDSA 差异的学生或研究人员。默认读者具备基本的公钥密码概念,但不需要你记住复杂的数论证明,我会把公式背后的“为什么”用大白话拆开讲。
2. SM2 的底层:椭圆曲线上的加法群到底在干嘛
2.1 从一条看起来很普通的方程说起
SM2 和 ECDSA 一样,底层都是椭圆曲线离散对数难题。SM2 使用的是一类定义在素数域 GF(p) 上的 Weierstrass 曲线,标准方程长这样:
y² = x³ + ax + b(mod p)
其中 a、b、p 都是固定的推荐参数。“离散步”这个名词听起来吓人,但你可以把它理解成:给定一个起点 G,对它做 k 次“椭圆曲线点加法”得到点 P=kG,当我们只知道 P 和 G 时,想反推出 k 是多少,目前没有多项式时间的算法。这个 k 就是私钥,P 就是公钥。
打个比方:我们在一条环形跑道上走路,每步的步长是固定的,走了很多圈之后告诉你现在在哪个位置,让你猜一共走了多少步。日历翻起来很费劲,但按固定步长往前却非常简单。SM2 的安全根基,就是这种“正向易、反向难”的不对称性。
注意和实数域椭圆曲线做区分——SM2 里的点坐标全部是模 p 意义下的整数,不是连续的几何曲线。你看到的曲线图只是帮助理解,实际运算全部是有限域上的模运算,这也是能落到 CPU 指令、FPGA、密码机里高效实现的前提。
2.2 点加、倍点与标量乘法
椭圆曲线上定义了一个“加法”:两个点 P、Q 做点加,得到第三个点 R,几何意义是过 P、Q 画直线交曲线于第三点,再做关于 x 轴的镜像。若 P=Q,则做切线取交点再镜像,这就是倍点。标量乘 kG 本质上就是做了 k 次点加,但实际实现不会傻傻地循环 k 次,而是用“先倍后加”的二进制展开法,把复杂度控制在 O(log k) 个点运算级别。
SM2 推荐曲线的位数是 256 位,意味着私钥是 256 比特的随机整数。一次标量乘大约需要做 256 次倍点和约 128 次点加。实时性能如何?我自己在一台普通的服务器 CPU 上跑 OpenSSL 的 SM2 路径,单次签名大约在几百微秒级别,这个速度对绝大多数业务系统来说完全够用,只有在极高并发签名场景下才需要考虑用密码机或专用硬件加速。
2.3 SM2 曲线参数与 P-256 的差异
很多人问过:SM2 曲线和 NIST P-256 相比,安全强度差多少?答案是不相上下。两者都是 256 位素数域曲线,SM2 的 p 值接近 2²⁵⁶,不过具体参数不同,不能直接把 P-256 的密钥拿来当 SM2 用。SM2 标准里推荐的曲线参数有以下关键几项:
| 参数 | 说明 |
|---|---|
| p | 定义有限域的素数模数 |
| a、b | 曲线方程系数 |
| G | 基点(生成元),标量乘的起点 |
| n | 基点 G 的阶,也就是 kG 的周期,签名运算里的重要模数 |
| h | 余因子,SM2 取 1,表示曲线上的点基本都能由一个阶为 n 的循环群生成 |
我发现很多初学者会混淆 n 和 p:p 是坐标运算所在的域,n 是私钥 k 和签名中间量参与的群阶。签名算法里对 n 取模,加密过程里点的运算对 p 取模。这个区分搞不清楚,后面调试异常时很容易懵。
3. SM2 密钥对生成与基础格式
3.1 密钥生成流程
SM2 密钥对生成非常简单,就三步:
- 随机生成一个大整数 d,要求 1 ≤ d ≤ n-2。
- 计算公钥 P = dG。
- 输出私钥 d 和公钥 P=(xP, yP)。
这里面唯一需要警惕的是随机数质量。私钥如果是从一个强度不足的随机源里产生的,敌人只需要缩小搜索空间,就能在现实时间内暴力穷举出 d。生产环境中生成私钥一定要用加密安全伪随机数生成器(CSPRNG),比如操作系统提供的 /dev/urandom,或者硬件随机数芯片。我亲眼见过某个项目为了“可复现测试”把私钥内嵌在代码里,结果上线后被安全扫描抓了个正着,这就是没有区分“测试数据”和“生产密钥”的典型反面教材。
3.2 公钥的两种编码形式
SM2 公钥本质上是一个椭圆曲线点 (xP, yP),坐标为两个 256 位整数。日常传输时需要序列化成字节串,常见两种格式:
- 未压缩格式:前缀 0x04,后接 xP(32字节)|| yP(32字节),共 65 字节。最直观,也最通用。
- 压缩格式:前缀 0x02 或 0x03,后接 xP(32字节),共 33 字节。利用曲线方程知道 xP 后,yP 只有两个可能值,前缀标记取哪一个。
大部分 SDK 默认生成 65 字节的未压缩公钥,但有些硬件密码机输出的是压缩格式。做系统对接时最容易出现的问题,就是一边把压缩公钥塞进只支持非压缩格式的接口,或者反过来。我建议在接口层做一次公钥格式的统一转换,不要指望所有上游和下游都用同一种格式。
3.3 ASN.1 与 PEM 封装
在证书、PKCS#8、PKCS#10 等场景中,SM2 公钥和私钥都会按 ASN.1 DER 编码封装。比如 SM2 私钥在 PKCS#8 结构里,会带一个算法标识符指出是 SM2,再跟一组 OCTET STRING 表示私钥值。从 OpenSSL 里导出的 SM2 私钥 PEM 文件,通常长成下面这样(内容经过省略展示):
-----BEGIN PRIVATE KEY----- MIGHAgEAMBAGByqGSM49AgEGBSuBBAAoBGYwZAIBAQQg<私钥base64>... -----END PRIVATE KEY-----这里算法 OID 有特定的一段,很多解析库就是靠它来识别 SM2 类型。我记得第一次对接某 CA 系统时,对方给的 sm2 私钥文件里面带着“EC PRIVATE KEY”标识,但 OID 不是 SM2 专属的,结果我们的解析器把它当普通 EC 密钥去跑了,曲线参数对不上,直接报错。后来统一改成按 OID 识别,才彻底解决这类兼容性问题。
4. SM2 加密与解密流程拆解
4.1 为什么 SM2 既是加密算法又不是“纯”公钥加密
RSA 可以直接拿公钥对一小块数据做幂运算加密,SM2 不一样。SM2 采用的是基于椭圆曲线的集成加密方案(类似 ECIES 的思路),本质上是“公钥运算 + KDF + 对称异或”的组合。你拿 SM2 公钥加密一段任意长度的数据,实际流程是这样的:
- 生成一个临时随机数 k;
- 计算点 C1 = kG;
- 计算点 S = kP(P 为接收方公钥),得到共享密钥材料 (x2, y2);
- 用 KDF(密钥派生函数)从 x2||y2 派生出密钥流 t,长度与明文等长;
- C2 = 明文 XOR t;
- 计算 C3 = SM3(x2 || 明文 || y2),作为校验值。
最终密文输出是 C1 || C3 || C2,这里要注意顺序:SM2 标准里推荐的密文顺序是先 C1(点),再 C3(摘要),最后 C2(密文主体)。这个顺序和某些老版本国密实现里“C1||C2||C3”的顺序不一样,直接导致跨平台互通时三方库互相解不开的经典故障。所以对接之前,第一件事就是确认对方用的密文排列顺序是什么。
4.2 解密流程
接收方拿到密文后,用自己的私钥 d 做如下操作:
- 从密文中拆分出 C1、C3、C2;
- 验证 C1 确实是椭圆曲线上的点,防止恶意构造的无效点攻击;
- 计算 S' = dC1,得到 (x2, y2),注意 dC1 = d(kG) = k(dG) = kP,所以双方得到的共享点是同一个;
- 用同样的 KDF 从 x2||y2 派生密钥流,与 C2 异或还原明文 M';
- 计算 SM3(x2 || M' || y2),与 C3 比对,一致则接受,不一致则解密失败。
这里“先摘要后解密”的设计,相当于给解密过程加了完整性校验。有人问为什么不像 RSA 那样纯靠非对称运算直接解出明文,这是因为 SM2 底层的“离散对数困难”本身并不能直接承载任意长度数据的加密,必须借助对称密码学组件搭桥,这也是现代公钥加密通用的混合设计的体现。
4.3 KDF 与 SM3 在其中的角色
SM2 中默认的 KDF 是围绕 SM3 哈希构造的,可以理解为一个不断对输入加计数器、反复做哈希、把输出拼接成所需长度的过程。它的输入是共享点的 x2||y2,输出是和明文等长的密钥流。和真正意义上的对称加密不同,这里的 XOR 操作没有引入额外的分组密码,所以 SM2 的“加密”其实更像一次带密钥的流式变换,配合 C3 做完整性校验。
我在实测中发现,当明文有特殊前缀(比如 JSON 字符串、文件头)时,KDF 生成的密钥流质量非常关键。如果某个库实现的 KDF 只生成了固定长度或者截断出错,最容易出现的现象就是:解密后半段数据全是乱码,前半段却是正确的。排查这种问题最快的办法,是拿同一份明文、同一个密钥,分别在两端跑一遍 KDF,比对中间派生字节流是否一致。
4.4 加密实现的关键注意事项
做 SM2 加密时,我还特别建议注意这几点:
- 临时私钥 k 不能重复使用。k 一旦泄露,攻击者可以通过公式推导出接收方私钥,这是所有基于 ECIES 方案的共同红线。
- C1 点必须做有效性校验(验证坐标是否满足曲线方程,并且是否在正确的群上),否则可能出现小群攻击或非法点攻击。
- 大明文不要直接用 SM2 硬扛。虽说 SM2 可以加密任意长度的数据,但每加密一次都是几百微秒的椭圆曲线点运算,如果拿它去加密几十 MB 的文件,性能会非常难看。最佳实践是:用 SM2 加密一个随机会话密钥,再用 SM4 加密真正的业务数据,各司其职。
5. SM2 签名、验签流程详解
5.1 签名前置:Z 值与待签名消息的处理
SM2 签名不是直接对原始消息做运算,而是要先算一个 Z 值。这个设计对比 ECDSA 是一个很值得注意的差异。Z 值计算公式为:
Z = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)
其中 IDA 是签名者身份标识,ENTLA 是 IDA 的比特长度编码。为什么要加入这些参数?因为同一个私钥可以用在不同的应用场景中,如果不把曲线参数、基点、公钥、身份信息绑定进摘要,就存在把一个场景下合法的签名挪到另一个场景下重放的风险。CERT 里如果用户的身份标识没有参与签名,签名就存在被替换或重绑定的可能。
实际签名里常用默认身份:IDA 取 1234567812345678(16字节),但很多系统为了安全或合规也会自定义 ID。对接时务必保证签名方和验签方使用完全相同的 ID,否则 Z 值不同,验签必然失败。我遇到过一个跨机构签章项目,两边都以为用了默认 ID,结果一方没有显式配置 ID,另一方配了一个自定义字符串,导致线上验签大面积失败。排查了两小时才发现是这个“小参数”不一致。
计算出 Z 后再和原始消息拼接,做一次 SM3 得到摘要 e:
e = SM3(Z || M)
这个 e 才是真正参与签名的消息摘要。
5.2 签名算法细节与 r、s 的生成
SM2 签名流程如下(设私钥为 dA):
- 生成随机数 k,1 ≤ k ≤ n-1;
- 计算点 (x1, y1) = kG;
- 计算 r = (e + x1) mod n,若 r = 0 或 r + k = n,则重新生成 k;
- 计算 s = ((1 + dA)^-1 × (k - r×dA)) mod n,若 s = 0 则重新生成 k;
- 签名输出为 (r, s),两者均为 256 位整数。
这个 r、s 的组合,长度固定为 64 字节(r 占 32 字节,s 占 32 字节)。经常有人拿它和 ECDSA 的 DER 编码签名对比,说 SM2 签名更长或更短,其实长度不能简单比较。ECDSA 的 DER 编码根据 r、s 的高位是否有 0 决定长度,会有浮动;SM2 的标准输出如果是纯粹的 r||s 拼接,则固定 64 字节。但如果经过 ASN.1 SEQUENCE 包装,长度又会变化。做签名长度检查时,别只看业务接口层,还要看底层编码到底有没有加 DER 头。
5.3 验签算法与数学推导
验签方持有公钥 PA,选择随机数 k 是无法预先知道的,但可以通过以下计算恢复出点 R:
- 先验 Z 值和摘要 e,公式与签名方完全一致;
- 计算 t = (r + s) mod n;
- 若 t = 0,拒绝签名;
- 计算点 R = sG + tPA;
- 计算 r' = (e + R_x) mod n;
- 若 r' = r,则验签通过,否则失败。
为什么这样做是可行的?我们把 R 展开:
sG + tPA = sG + (r+s)dAG = sG + r·dAG + s·dAG = sG(1+dA) + r·dAG
再看签名阶段 s 的定义:s = (1+dA)^-1 × (k - r·dA),两边同时乘以 (1+dA),得到 s(1+dA) = k - r·dA,即 k = s(1+dA) + r·dA。所以 R 的表达式等于 kG,也就是签名阶段计算出的点 (x1, y1)。这个推导是整个验签方程的魔术核心,理解它,签名验签就不再是一堆黑盒子函数调用。
5.4 签名安全与随机数质量
SM2 签名对随机数 k 的要求极高,甚至比加密还要高一个量级。为什么?因为签名方程里有私钥 dA,如果两次签名使用了同一个 k,而两个消息的摘要 e1、e2 是已知的,攻击者可以通过两个方程相减消去 dA,直接解出私钥。这不是理论风险,业内出现过真实事故,某钱包因为随机数复用导致私钥被还原,损失惨重。
所以在代码层做签名封装时,不要自己随便生成 k,应该交给经过验证的密码库处理。即便要自己实现,也建议采用 RFC 6979 风格的确定性随机数方案,用私钥加消息哈希派生 k,既避免随机源弱化,又能保证可复现性。我在对接某个老设备时,对方设备随机源质量很差,签名偶发失败,后来就是换成了确定性 k 生成方式,问题才稳定下来。
6. 实际项目里的应用架构与工具选型
6.1 典型的国密应用场景
从实际落地来看,SM2 最常见的几类应用场景大概是:
- 数字证书与 CA 体系:国密证书的签发、验证、双向 TLS 握手。
- 电子签章与合同签署:文档哈希用 SM3,摘要签名用 SM2,签章数据包通常还包含时间戳。
- 数据加密传输:比如金融交易报文、政务数据交换,先协商密钥再用 SM4 加密。
- 身份认证与登录:基于 SM2 的挑战-应答认证机制,替代传统口令认证。
可以想象一个电子合同签署平台的简化流程:用户客户端生成 SM2 密钥对,把公钥注册到服务端;签署合同时,客户端用私钥对待签文档的 SM3 摘要做 SM2 签名;服务端用公钥验签后,再生成一个随机会话密钥,用该用户的公钥做 SM2 加密,随后整个合同原文用 SM4 加密保存,会话密钥用 SM2 加密结果保存。这个架构里 SM2 既承担身份可信,又承担密钥保护,是双重的关键角色。
6.2 OpenSSL 与三方库的实操要点
OpenSSL 从较新版本开始已经原生支持 SM2 曲线。用 OpenSSL 命令行生成 SM2 密钥对很简单:
openssl ecparam -name SM2 -genkey -out sm2_private.pem openssl ec -in sm2_private.pem -pubout -out sm2_public.pem注意生成的私钥 PEM 如果直接用openssl ec -text查看,可以看到内部是 EC 私钥结构,算法 OID 标的是 SM2 曲线。做签名时,OpenSSL 要求显式指定摘要算法为 SM3:
openssl dgst -sm3 -sign sm2_private.pem -out sign.bin data.txt openssl dgst -sm3 -verify sm2_public.pem -signature sign.bin data.txt用 OpenSSL 做 SM2 加解密是另一套指令,因为 SM2 没有直接暴露在openssl enc里,需要借助pkeyutl:
openssl pkeyutl -encrypt -pubin -inkey sm2_public.pem -in plain.txt -out cipher.bin openssl pkeyutl -decrypt -inkey sm2_private.pem -in cipher.bin -out recover.txt这里pkeyutl默认的密文格式会遵循标准 C1C3C2 顺序(具体看版本)。不同版本 OpenSSL 对 SM2 的支持成熟度不同,老版本需要自己编译补丁,新版本省事很多。我建议在正式对接前,先做一个自测:自己生成密钥,自己签名自己验签,再验证一次跨语言互通,比如 OpenSSL 签的用某 Java SDK 验签,确保不会等联调时才发现格式不兼容。
6.3 Java、Go 等语言实现考量
Java SDK 原生橹橇橇并没有直接提供 SM2 实现,通常需要引入 Bouncy Castle 或国密改造增强包。Bouncy Castle 里的 SM2 支持已经比较完善,密钥格式和签名验签接口都能和 OpenSSL 互操作。但我建议特别注意两点:
- Bouncy Castle 中 SM2 的密钥对生成器需要初始化曲线参数,最好直接使用标准推荐参数模板,不要手写曲线参数,避免拼错一个 hex 导致线上线下验签不通过。
- 签名接口默认可能是 DER 编码输出,如果你要的是固定 64 字节的 r||s 拼接,需要做格式转换,反之亦然。做接口层约定时,一定要注明签名格式到底是 plain 还是 DER。
Go 语言生态里 SM2 支持也比较成熟,标准库虽然没内置,但开源社区有比较完善的国密套件,密文和签名格式都能和 OpenSSL 互通。跨语言通信时最省心的方式是统一用 ASN.1 DER 交换签名,因为纯 r||s 本身没有携带任何参数信息,一旦签名字节流被截断或数组越界,定位问题会非常痛苦。
7. 常见问题与排查技巧实录
7.1 跨平台验签失败
这是出现频率最高的问题。症状:A 系统用 C 库签名,B 系统用 Java 库验签,结果验签失败,两边分别自测都通过。优先排查顺序:
- Z 值相关参数是否一致,尤其是 ID 字符串是不是默认的 1234567812345678;
- 消息摘要 e 的输入顺序是否一致,是 SM3(Z || M) 还是 SM3(M);
- 公钥编码是否一个用压缩格式一个用未压缩格式;
- 签名格式是否一个输出 DER 一个输出 r||s;
- 密文/签名的字节顺序是否相同,C1C3C2 和 C1C2C3 是完全不同世界的产物。
7.2 解密时输出乱码或报“invalid cipher”
常见原因是密文字节顺序弄错。拿一段已知密文,先按 65 字节长度找到 C1 的起点,再把剩下的拆成 C3(32 字节)和 C2(剩余)。如果拆错了,KDF 出来的密钥流自然对不上,SM3 校验也会失败。还有一种情况是 C1 点没有做有效性校验,某些库遇到非法点会抛异常,而另一些库只是默默返回错误结果。
7.3 再谈随机数:k 的复用是灾难级风险
我在第 5.4 节已经提到过签名随机数复用会泄露私钥。这里再补充一个细节:SM2 加密过程中的临时 k 如果被随机源生成的序列有偏向性,比如高 128 位全是 0,攻击者可以利用这部分信息缩小密钥搜索空间。所以在生产环境,建议优先使用硬件随机数或由操作系统 CSPRNG 提供熵,密码库的“安全随机数”接口不要自己实现。
7.4 常见错误速查表
| 现象 | 大概率原因 | 解决思路 |
|---|---|---|
| 验签失败 | Z 值 ID 不一致 | 统一两边身份 ID |
| 验签失败 | 摘要算法用成了 SHA-256 而非 SM3 | 显式指定 SM3 |
| 解密乱码 | 密文格式 C1C2C3/C1C3C2 不一致 | 按标准确认排列顺序 |
| 解密报错 | C1 点未做合法性校验 | 先验点方程再继续 |
| 签名长度波动 | ASN.1 DER 头变化 | 确认接口要求的签名编码 |
| 私钥解析失败 | OID 识别错误 | 换用 OID 识别模式的解析库 |
| 性能卡顿 | 大量 SM2 加密大文件 | 改为 SM2 加密会话密钥 + SM4 加密数据 |
7.5 我踩过一次的坑:公钥压缩格式的误解
有一次我们跟某硬件密码机厂商联调,对方返回的公钥是 33 字节压缩格式。我方 SDK 默认读 65 字节,于是直接把前 33 字节当 x 坐标,后面补了一堆 0 作为 y 坐标。结果验签时当然失败。后来才意识到压缩格式不是“丢了一半坐标”,而是利用曲线方程恢复出 y,前缀 0x02/0x03 只是标记 y 的奇偶性。这个坑提醒我:不管对接什么设备,第一件事就是打开返回的字节流,数一数长度,看前缀,别想当然。
8. 最后再分享两个实操中的经验
我在实际项目里处理 SM2 相关的联调问题,总结出两条铁律。第一条是“加密解密、签名验签必须分开自测”。不要只测“自己干自己”的流程,一定要做“我方加密、对方解密”和“对方签名、我方验签”的双向交叉测试。因为很多国密 SDK 内部实现细节差异很大,单向自测通过根本不代表互通没问题。第二条是“日志里不要打印私钥和 k,但一定要打印公钥指纹和摘要”。排查问题时,公钥指纹和消息摘要能帮助快速定位是参数不一致还是算法实现有差异,私钥进日志则会让安全审计直接把你请去喝茶。
另外有一点很容易被忽略:SM2 配合的哈希是 SM3,但有些场景为了性能或兼容老系统,会退而求其次用 SHA-256。安全上当然不建议,但如果你确实是在维护一个历史遗留系统,改不了摘要算法,至少在接口文档里显眼地标识出“这个通道不是完整国密”,以免后来人误判安全等级。
全文不至于面面俱到,但核心的“原理、加密、解密、签名、验签”已经完整覆盖。我建议你下一步直接动手做一个小工具:生成一对 SM2 密钥,用 OpenSSL 签个名,再写几行代码调用国密 SDK 验签,把加密解密流程跑通一遍。踩过的坑看十遍不如亲手踩一遍,尤其是字节顺序、ID 参数、摘要算法这些细节,一次实操胜过十次阅读。当年我就是靠一遍一遍地对比字节流,才把 SM2 从“会调用”变成“看得懂”,这个转变对排查线上问题特别值。