4. SM2公钥加密原理
SM2 公钥加密,说白了就是一套混合加密系统。我刚开始研究国密那会儿,也被这个结构惊艳过——它把椭圆曲线密钥交换、对称加密和哈希校验巧妙地揉在了一起。你想想看,要是直接用椭圆曲线去加密一个大文件,那计算量得有多大?所以国密标准的设计者选了另一条路:用 ECDH 协商出一个共享密钥,再用这个密钥去跑对称加密。既保证了公钥加密的便利性,又保住了对称加密的高性能。
4.1 混合加密架构
混合加密的核心思路很简单:公钥技术做密钥协商,对称算法做数据加密。SM2 加密在标准里被定义为一个完整的公钥加密方案,但内部其实拆成了三步:
- 密钥协商层:发送方生成临时 ECDH 密钥对,结合接收方公钥算出一个共享密钥点。
- 对称加密层:通过 KDF 从共享密钥点派生出对称密钥,再用这个密钥加密明文。
- 完整性校验层:对关键参数做 SM3 哈希,防止密文被篡改。
关键认知:SM2 加密对外表现为"公钥加密",内部却是混合架构。面试时经常有人把 SM2 和 ECDH 混为一谈,其实 SM2 加密是 ECDH + KDF + 对称加密 + SM3校验 的组合体。
我在一个国产化文件加密项目中,就是用 SM2 做密钥封装、SM4 做数据加密。20MB 的文件,纯 SM2 逐比特加密可能要几分钟,混合方案几秒就搞定了。差距就是这么大。
4.2 椭圆曲线 Diffie-Hellman(ECDH)
ECDH 是整个加密流程的"发动机"。它负责在发送方和接收方之间建立一个共享的秘密点,而这个点只有双方能算出来。
发送方要做的是:
- 生成一个随机数
r,范围在[1, n-1],n 是 SM2 曲线的阶。 - 计算临时公钥
R = r·G,G 是曲线基点。 - 用接收方公钥
Pb计算共享点P = (x2, y2) = r·Pb。
这里有个细节:r 必须每次重新生成,不能复用。我见过一个项目把临时密钥缓存起来重复使用,结果每次加密的 R 都一样,攻击者直接就能恢复出共享密钥——嗯,这是很典型的安全事故。
经验之谈:生成 r 时一定要用密码学安全的随机数发生器。标准库的 SecureRandom 或 CryptGenRandom 都行,别用 rand() 或者 Math.random(),那玩意儿 predictability 太差。
4.3 KDF 密钥派生函数
共享点 P 是一个椭圆曲线上的点,坐标是 256 位的大整数。我们不能直接用 x2 或 y2 做密钥——它们不是均匀分布的,直接用会有安全风险。这时候就需要 KDF 来"提炼"一下。
SM2 标准里的 KDF 基于 SM3 哈希函数实现。它的工作方式是:
- 输入:
x2 || y2(比特串拼接)和期望的输出长度klen。 - 内部用一个计数器迭代 SM3,生成足够长的密钥流。
- 如果输出全是零比特,就报错,要求重新生成
r。
举个例子,假设明文长度是 128 位,KDF 就会输出 128 位的密钥 t。这个 t 和明文做异或,就完成了加密。我个人习惯把 KDF 的输出长度和明文长度严格对齐,少一位都不行。
注意:KDF 输出全零的情况虽然概率极低,但标准要求必须处理。如果你在实现时忽略了这一步,合规审计大概率过不了。
4.4 加密流程详解
好了,我们把上面几个组件串起来,看看一次完整的 SM2 加密长什么样。下面这张图展示了整个流程:
具体到每一步,标准里是这样定义的:
- 生成随机数
r ∈ [1, n-1],计算R = r·G。 - 计算
P = (x2, y2) = r·Pb。如果 P 是无穷远点,报错退出。 - 用 KDF 从
x2 || y2派生密钥t,长度等于明文 M 的比特长度。 - 如果
t全为 0,回到步骤 1 重新生成r。 - 计算
C2 = M ⊕ t(异或加密)。 - 计算
C3 = SM3(x2 || M || y2)。 - 输出密文
C = R || C2 || C3。
下面是一个 Python 风格的伪代码,把核心逻辑展示出来:
def sm2_encrypt(M, Pb):
while True:
r = secure_random_in_range(1, n-1)
R = scalar_mult(r, G)
P = scalar_mult(r, Pb) # P = (x2, y2)
if is_infinity(P):
continue
t = kdf(x2 || y2, len(M)) # 基于 SM3 的 KDF
if all_zero(t):
continue
C2 = xor(M, t)
C3 = sm3_hash(x2 || M || y2)
return R || C2 || C3
个人习惯:我在实现时会把 R 的压缩编码和非压缩编码都支持,但推荐用非压缩格式(65 字节),因为大多数互操作场景都默认这个格式。压缩格式虽然省空间,但容易引入坐标解析的 bug。
4.5 解密与验证
解密是加密的逆过程,但多了一个完整性校验的步骤。接收方用自己的私钥 d 恢复共享点,然后验证 C3 是否匹配。
解密流程:
- 从
C中提取R、C2、C3。 - 验证
R在曲线上,不是无穷远点。 - 计算
P = (x2, y2) = d·R。 - 用 KDF 从
x2 || y2派生密钥t,长度等于C2的比特长度。 - 计算
M' = C2 ⊕ t。 - 计算
u = SM3(x2 || M' || y2)。 - 如果
u != C3,说明密文被篡改,报错。否则输出M'。
加密和解密的过程可以归纳成下面的对比:
| 阶段 | 加密(发送方) | 解密(接收方) |
|---|---|---|
| 密钥输入 | 接收方公钥 Pb | 自己的私钥 d |
| 临时密钥 | 生成随机 r,计算 R = r·G | 从密文提取 R |
| 共享点 | P = r·Pb | P = d·R |
| 派生密钥 | t = KDF(x2‖y2, klen) | t = KDF(x2‖y2, klen) |
| 数据操作 | C2 = M ⊕ t | M' = C2 ⊕ t |
| 校验值 | C3 = SM3(x2‖M‖y2) | u = SM3(x2‖M'‖y2),比对 u ?= C3 |
我曾经调试过一个解密失败的问题,折腾了大半天,最后发现是接收方在提取 R 时多读了一个字节。嗯,从那以后我习惯在编码格式上加个长度前缀,避免这种低级错误。
4.6 避坑指南
SM2 加密看似简单,实际落地时坑不少。我总结了几个高频问题:
- 公钥未验证:加密前必须检查
Pb是否在曲线上,且不是无穷远点。否则可能被无效公钥攻击。 - 随机数复用:临时私钥
r每次必须重新生成。一旦复用,攻击者可以通过两次密文恢复出共享密钥。 - KDF 长度不匹配:派生密钥的长度必须严格等于明文的比特长度。多了或少了都会导致解密失败。
- C3 校验遗漏:解密时如果不做
C3校验,攻击者可以篡改密文而不被发现。 - 编码格式混乱:
R的编码格式(压缩/非压缩)必须在通信双方之间约定好,否则提取坐标时会出错。
重要:SM2 加密的 C3 计算用的是 SM3(x2 || M || y2),不是 SM3(x2 || C2 || y2)。我在标准文档里确认了好几遍,这个顺序不能搞反。解密时校验也得用明文 M',不是密文 C2。
好了,SM2 公钥加密的核心内容就是这些。混合架构、ECDH 密钥交换、KDF 派生、再加上 SM3 完整性校验——这几个组件组合在一起,构成了一个既安全又高效的国密加密方案。你在面试中如果能把这个设计逻辑讲清楚,面试官基本都会点头。