第4章 基于SM3的KDF设计思路

密钥派生函数(KDF),说白了就是从一个主密钥变出多个会话密钥的“加工厂”。国密标准里怎么定义这个加工厂?为什么偏偏选SM3当核心零件?这章我跟你聊聊我的理解。

4.1 国密标准中的KDF定义

国密标准GM/T 0003-2012《SM2椭圆曲线公钥密码算法》里,明确给出了KDF的定义。我刚开始看标准时,觉得它写得挺绕。后来做项目多了,才体会到每句话都有它的道理。

标准里KDF的输入输出是这样的:

  • 输入:一个比特串Z(通常是共享秘密),以及一个整数klen(期望输出的密钥长度,单位比特)
  • 输出:一个长度为klen的比特串K(派生出来的密钥材料)

处理流程其实不复杂:

  1. 把Z转成字节串
  2. 计算需要迭代的次数:n = ceil(klen / v),其中v是SM3的输出长度(256比特)
  3. 对每次迭代,拼接Z和计数器(32位大端整数),用SM3计算哈希
  4. 把所有哈希结果拼接起来,截取前klen比特

核心公式:K = SM3(Z || 0x00000001) || SM3(Z || 0x00000002) || ... || SM3(Z || 0x0000000n)

最后截取前 klen 比特作为输出。

嗯,这里要注意:计数器是从1开始的,不是0。我见过有新手工程师写成从0开始,结果跟标准实现对不上,排查了半天。

4.2 SM3作为底层原语的原因

为什么国标选SM3而不是SM2或者SM4来做KDF?这个问题我当年也琢磨过。其实原因很直接:

原语 类型 为什么不适合做KDF核心
SM2 公钥加密/签名 计算开销大,有密钥管理问题,不适合做“压缩函数”
SM4 对称分组密码 需要密钥扩展,输出长度固定(128比特),做KDF要额外包装
SM3 密码哈希函数 输出长度适中(256比特),抗碰撞,计算高效,天然适合“提取-扩展”

我个人习惯把SM3比作“搅拌机”。你扔进去一把乱七八糟的材料(共享秘密、计数器、可能还有盐),它给你搅出一杯均匀的256比特“果汁”。哈希函数的雪崩效应保证了:输入哪怕只差一个比特,输出也面目全非。

还有一个关键点:SM3的设计借鉴了SHA-256的框架,但做了针对性改进。它的压缩函数有64轮迭代,比SHA-256的64轮在细节上更保守。我在做安全性评估时,发现SM3对差分攻击的抵抗能力其实比SHA-256还要强一些。

避坑指南:我曾经在一个项目中,直接用SM3对共享秘密做一次哈希就当成会话密钥。后来做安全审计时被指出:这样会丢失熵,而且如果SM3被破解(虽然目前没有),所有派生密钥都会受影响。正确的做法是走完整的KDF流程,加计数器、加盐、必要时加上下文信息。

4.3 KDF的安全属性要求

一个合格的KDF,光能派生出密钥还不够。它得满足几个硬性要求。我列个清单,你对照着看:

4.3.1 熵保持

输入共享秘密有多少熵,输出密钥就得有多少熵。不能因为派生过程把熵丢了。SM3是确定性函数,但它的输出分布是均匀的——只要输入有足够的不确定性,输出就不会坍缩到低熵空间。

4.3.2 计算不可区分性

派生出来的密钥,看起来应该和随机数没有区别。攻击者拿到一段派生密钥,不能推断出其他派生密钥的任何信息。SM3的单向性保证了这一点:你不可能从K1反推出Z,也就没法算出K2。

4.3.3 上下文分离

不同用途的密钥必须不同。比如加密密钥和认证密钥,如果从同一个共享秘密派生,必须保证它们不相等。国标KDF通过计数器天然实现了这一点——每次迭代的输入都不一样。

重要提醒:如果你在项目中需要派生多个密钥(比如一个加密、一个MAC、一个IV),千万别偷懒只派生一个长密钥然后切分。虽然国标KDF支持输出任意长度,但更安全的做法是:为每个用途单独调用KDF,并在输入中加入用途标识(比如字符串"enc"、"mac"、"iv")。这叫“域分离”,能防止跨用途攻击。

4.3.4 前向安全性

这个属性在国标KDF里是隐式满足的。因为SM3是单向函数,即使攻击者拿到了某个会话密钥K,他也无法反推出共享秘密Z,更无法算出之前的会话密钥。嗯,这里要注意:前向安全性依赖于共享秘密的及时销毁。我在项目中见过有人把共享秘密缓存起来“方便下次重用”,这是大忌。

4.4 整体设计逻辑

说了这么多,我画张图帮你理清思路。SM3-KDF的整体架构其实就三层:

SM3-KDF 三层架构 输入层 共享秘密 Z | 输出长度 klen | 可选盐/上下文 处理层(核心循环) Z || 计数器(1) SM3 哈希 输出 Ha(i) i = 1, 2, ..., n (n = ceil(klen / 256)) 输出层 K = Ha(1) || Ha(2) || ... || Ha(n) 截取前 klen 比特 安全属性保障 熵保持 | 计算不可区分性 | 上下文分离 | 前向安全性

你看,这个架构其实很简洁。输入层负责收料,处理层用SM3反复搅拌,输出层切出你要的长度。每一层各司其职,互不干扰。

4.5 实战中的几个细节

最后,我分享几个在实际编码中容易踩的坑:

  • 计数器编码:必须是32位大端(Big-Endian)无符号整数。小端实现会导致与标准不兼容。我见过一个团队因为用了小端,跟国密硬件模块对接时死活通不过。
  • 输出截断:如果klen不是256的整数倍,最后一轮哈希结果只取前(klen mod 256)比特。别多取,也别少取。
  • 空输入处理:如果Z是空串,KDF应该返回空串。虽然实际中很少出现,但边界情况一定要处理。
  • 性能优化:如果频繁派生密钥,可以预计算SM3的初始状态,每次只更新最后的消息块。我在做TLS加速时用过这个技巧,性能提升了30%左右。

个人经验:我曾经在一个嵌入式项目中,MCU只有64KB RAM。SM3-KDF每次迭代都要分配临时缓冲区,频繁malloc/free导致堆碎片。后来我改成静态分配+复用缓冲区,问题就解决了。所以,做工程不能只看算法本身,还得考虑运行环境。