4、基于SM3的KDF实现:SM3-KDF构造方法、密钥长度协商、安全参数选择
密钥派生函数(KDF),说白了就是从一个主密钥材料里,派生出多个会话密钥、加密密钥、认证密钥的工具。你想想看,如果每次通信都直接用主密钥去加密,那主密钥暴露的风险就太大了。所以我们需要一个标准化的方法,把主密钥“变”出一堆子密钥来。
国密标准里,基于SM3的KDF是GM/T 0005-2012中规定的。我最早接触这个标准是在一个政务云的项目里,当时需要做密钥生命周期管理,发现直接用SM3哈希拼接的方式并不安全,必须严格按照标准来。嗯,这里要注意,自己拍脑袋设计的KDF往往有漏洞。
4.1 SM3-KDF的构造方法
SM3-KDF的核心思想其实不复杂:用SM3哈希函数,结合密钥材料、上下文信息、以及一个计数器,反复迭代生成足够长的密钥比特流。标准里给出的构造公式是这样的:
K = SM3(Z || counter) // 当所需密钥长度 ≤ 256 bits 时
K = SM3(Z || 0x00000001) || SM3(Z || 0x00000002) || ... // 当所需密钥长度 > 256 bits 时
其中:
- Z:共享秘密(通常是ECDH交换后的共享密钥)
- counter:32位大端序计数器,从0x00000001开始递增
- K:派生出的密钥比特流
我个人习惯把这种构造叫做“哈希链式派生”。为什么用计数器而不是随机数?因为计数器能保证每次派生结果唯一,且不需要额外传输随机数,节省带宽。
核心要点:SM3-KDF的输出是可截断的。如果你只需要128位的AES密钥,那就取前128位;如果需要192位,就取前192位。但要注意,截断不能破坏密钥的熵——说白了,你不能只取最低的64位,那样熵就减半了。
我在一个物联网项目中遇到过一个问题:设备端和服务器端分别计算KDF,结果不一致。排查了半天,发现是计数器的字节序搞反了。一端用了大端,另一端用了小端。所以这里要强调:计数器必须使用大端序(Big-Endian),这是标准里明确写的。
4.2 密钥长度协商
密钥长度协商,听起来高大上,其实就是在通信双方建立连接时,确定到底要派生多长的密钥。这个环节如果处理不好,就会出现“我要128位,你给我256位”的尴尬情况。
标准的做法是在握手协议中增加一个密钥长度参数。我建议用以下方式:
| 参数名称 | 长度(字节) | 说明 |
|---|---|---|
| EncKeyLen | 2 | 加密密钥长度(单位:字节),如16表示128位 |
| MacKeyLen | 2 | MAC密钥长度(单位:字节),如16表示128位 |
| KdfSalt | 8 | 可选的盐值,用于增加派生结果的随机性 |
你想想看,如果双方支持的密钥长度不同怎么办?我记得在某个金融项目中,服务端只支持256位SM4密钥,但客户端只支持128位。最后我们采用了一个折中方案:取双方支持的最大公共长度。如果一方支持128-256,另一方只支持128,那就用128。
实战建议:密钥长度协商最好放在握手阶段的前几条消息中。我曾经见过一个设计,把密钥长度放在加密后的消息体里——这就成了“先有鸡还是先有蛋”的问题:解密需要密钥,但密钥长度又在加密数据里。这种循环依赖一定要避免。
4.3 安全参数选择
安全参数选择,说白了就是决定“用多长的密钥”、“迭代多少次”、“加不加盐”。这些参数直接影响安全性。我根据GM/T 0005-2012和实际工程经验,整理了一个参数选择表:
| 应用场景 | 推荐密钥长度 | 迭代次数 | 是否需要盐值 |
|---|---|---|---|
| SM4对称加密 | 128位 | 1 | 推荐 |
| SM2签名辅助 | 256位 | 1 | 可选 |
| 密钥派生(多层) | 256位 | ≥2 | 必须 |
| 口令派生(PBKDF场景) | 256位 | ≥10000 | 必须 |
这里有个容易踩的坑:迭代次数不是越多越好。我曾经在一个嵌入式设备上把迭代次数设成了100000,结果密钥派生花了3秒钟——设备直接超时了。对于实时性要求高的场景,迭代次数1-2次就够用了。只有口令派生这种对抗暴力破解的场景,才需要高迭代次数。
警告:不要使用固定的盐值!盐值必须每次随机生成。如果盐值固定,那么相同的密钥材料每次派生出的结果都一样——这就失去了KDF“每次会话密钥不同”的意义。我见过一个项目,盐值写死在代码里,结果所有会话的密钥都一样,跟没派生一样。
4.4 SM3-KDF的完整流程图
下面这张图展示了SM3-KDF从输入到输出的完整流程。我特意把“密钥长度协商”和“安全参数选择”两个环节也画进去了,这样你就能看到整个密钥派生体系的全局。
4.5 代码实现示例
下面是一个SM3-KDF的Python实现片段。我故意去掉了异常处理,只保留核心逻辑,方便你看清楚每一步在做什么。
import struct
from sm3 import sm3_hash # 假设已有SM3实现
def sm3_kdf(z: bytes, key_len: int, salt: bytes = b'', iterations: int = 1):
"""
SM3-KDF 密钥派生函数
:param z: 共享秘密(字节串)
:param key_len: 所需密钥长度(字节)
:param salt: 盐值(可选)
:param iterations: 迭代次数
:return: 派生密钥
"""
output = b''
counter = 1
while len(output) < key_len:
# 构造输入:Z || counter || salt
counter_bytes = struct.pack('>I', counter) # 大端序32位
data = z + counter_bytes + salt
# 迭代哈希
h = data
for _ in range(iterations):
h = sm3_hash(h)
output += h
counter += 1
# 截断至所需长度
return output[:key_len]
小技巧:上面的代码里,struct.pack('>I', counter) 中的 >I 表示大端序无符号32位整数。如果你用 <I(小端序),结果就会和标准不兼容。我建议在代码注释里显式标注字节序,方便后来人维护。
4.6 避坑指南
最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路:
- 计数器初始值:标准规定从0x00000001开始,不是0。我曾经见过有人从0开始,结果第一个派生结果和直接哈希一样,失去了KDF的意义。
- 盐值重用:同一个盐值不要用于不同的密钥派生场景。比如加密密钥和MAC密钥,即使主密钥相同,也要用不同的盐值或上下文标识。
- 迭代次数与性能的平衡:对于实时通信,迭代次数1-2次足够;对于口令存储,建议10000次以上。但要注意嵌入式设备的算力限制。
- 密钥长度不是越长越好:SM4只需要128位,你派生256位然后截断,多出来的计算都是浪费。按需取用就好。
嗯,关于SM3-KDF的实现,核心内容就是这些了。记住,密钥派生不是简单的哈希拼接,标准里的每一个细节——计数器、字节序、盐值、迭代次数——都有它的道理。严格按照标准来,才能保证你的密钥派生是安全的。