密钥派生函数(KDF):从设计目标到SM2实战

密钥派生函数,简称KDF,说白了就是「从一个秘密值变出多个密钥」的工具。你想想看,我们在SM2密钥交换或加密过程中,往往只需要一个共享秘密,但实际使用中可能需要会话密钥、MAC密钥、IV向量……总不能拿同一个密钥到处用吧?这时候KDF就派上用场了。

我个人习惯把KDF看作一个「密钥榨汁机」——你扔进去一个种子,它给你榨出指定长度的密钥材料。嗯,这个比喻虽然糙了点,但道理没错。

KDF设计目标:为什么不能直接哈希?

有人可能会问:「我直接用SM3哈希一下不就行了?」我在项目中确实见过这种写法,结果后来出了问题——哈希输出长度固定,而且缺乏上下文隔离。KDF的设计目标其实很明确:

  • 长度可变:能生成任意长度的密钥材料,不局限于哈希输出长度
  • 上下文隔离:同样的输入,加上不同的「上下文标签」,输出完全不同
  • 抗碰撞与抗预演:即使部分输出泄露,也不能推导出其他输出或原始种子
  • 计算高效:不能比原始哈希慢太多,否则影响性能

核心要点:KDF不是简单哈希,而是「带上下文的可扩展哈希」。SM2标准中使用的KDF基于SM3,但增加了计数器机制和长度编码,确保每次调用都产生独立输出。

SM2 KDF实现细节:国标是怎么规定的?

SM2的KDF定义在GM/T 0003.1-2012标准中。我当初第一次看标准文档时,说实话有点晕——公式符号太多。但拆开来看,逻辑其实很清晰。

标准中KDF的输入参数有四个:

参数 含义 典型长度
Z 共享秘密(种子) 取决于密钥交换或加密
klen 期望输出的比特长度 如128、192、256
其他输入 标准中可选的上下文信息 通常为空或固定值

输出就是指定长度的密钥比特串。实现时需要注意一个关键点:SM3的输出是256比特(32字节),如果klen不是32的整数倍,最后一轮需要截断。

我曾经踩过的坑:有一次实现SM2加密时,我忘了处理klen不是32字节整数倍的情况,结果解密端一直报错。查了两天才发现是KDF最后一轮截断没做对。所以这里提醒大家:截断操作必须精确到比特级别,不是简单的字节截断。

基于SM3的KDF流程:一步步拆解

好了,我们直接看流程。SM2 KDF的核心思想是:用SM3反复哈希,每次哈希时加入一个递增的计数器。

具体步骤如下:

  1. 计算轮数:n = ceil(klen / v),其中v=256(SM3输出比特长度)
  2. 初始化计数器:ct = 0x00000001(32位大端表示)
  3. 循环计算:对i从1到n,计算 Ha_i = SM3(Z || ct),每次ct递增1
  4. 拼接输出:K = Ha_1 || Ha_2 || ... || Ha_n
  5. 截断:取K的前klen比特作为最终输出

你看,其实不复杂。但这里有个细节:计数器的编码方式。标准规定ct用32位大端无符号整数,这意味着最多可以派生2^32轮,每轮256比特,总共128G比特——实际应用中完全够用。

我的建议:实现时把Z和ct的拼接操作放在循环外面预处理,只更新ct部分,可以节省不少SM3的上下文切换开销。我在一个嵌入式项目中这么优化过,性能提升了约15%。

流程图:SM2 KDF核心逻辑

下面这张图把整个流程串起来了,我建议你对照着看代码实现:

SM2 KDF 核心流程(基于SM3) 输入:Z, klen n = ceil(klen / 256) ct = 0x00000001 i ≤ n? 是 Ha_i = SM3(Z || ct) ct = ct + 1 否 K = Ha_1 || Ha_2 || ... || Ha_n 取前 klen 比特 → 输出 ⚠ 注意事项 • 计数器ct为32位大端 • 最后一轮可能需截断 • 截断精确到比特级别

代码实现:SM2 KDF的C语言参考

下面是我在实际项目中用过的KDF实现片段,去掉了工程化的边界检查,保留了核心逻辑:

// SM2 KDF 实现(基于SM3)
// 输入: Z - 共享秘密, zlen - Z字节长度
//       klen - 期望输出比特长度
// 输出: K - 派生密钥
int sm2_kdf(const uint8_t *Z, size_t zlen, 
            size_t klen, uint8_t *K) {
    size_t v = 256;          // SM3输出比特长度
    size_t n = (klen + v - 1) / v;  // 轮数
    size_t i;
    uint32_t ct = 0x00000001;
    uint8_t ha[32];          // SM3输出缓冲区
    uint8_t *buf;
    size_t buf_len = zlen + 4;  // Z + 4字节计数器
    
    buf = malloc(buf_len);
    if (!buf) return -1;
    memcpy(buf, Z, zlen);    // 拷贝Z
    
    for (i = 0; i < n; i++) {
        // 将计数器以大端方式写入buf末尾
        buf[zlen + 0] = (ct >> 24) & 0xFF;
        buf[zlen + 1] = (ct >> 16) & 0xFF;
        buf[zlen + 2] = (ct >> 8) & 0xFF;
        buf[zlen + 3] = ct & 0xFF;
        
        sm3_hash(buf, buf_len, ha);  // SM3计算
        
        // 最后一轮可能需要截断
        size_t copy_len = 32;
        if (i == n - 1) {
            size_t remain = klen - (n - 1) * v;
            copy_len = (remain + 7) / 8;  // 比特转字节
        }
        memcpy(K + i * 32, ha, copy_len);
        ct++;
    }
    
    free(buf);
    return 0;
}

关键点说明:上面的代码中,copy_len的计算我用了(remain + 7) / 8,这是比特到字节的向上取整。但注意,如果remain不是8的整数倍,最后一字节中多余的比特位必须置0。标准里写得很清楚,但实现时容易忽略。

实战中的避坑指南

我在多个SM2落地项目中积累了一些经验,这里分享几个最常见的坑:

  • 计数器溢出:虽然2^32轮几乎不可能用完,但如果你在循环中用了uint32_t,ct++溢出后从0xFFFFFFFF变回0x00000000,会导致重复输出。我建议加一个溢出检查,或者直接用uint64_t。
  • 大端小端混淆:SM2标准明确要求计数器用大端编码。如果你在x86平台上跑,别忘了做字节序转换。我曾经见过一个项目,在ARM小端平台上忘了转换,结果KDF输出全错。
  • 性能优化:如果klen很大(比如要派生1MB密钥),每次循环都重新分配buf是不划算的。我习惯在循环外分配好,只更新计数器的4个字节。

我的个人习惯:在实现KDF时,我会额外加一个「上下文标签」参数。虽然SM2标准没强制要求,但加上后可以避免不同协议模块之间的密钥混淆。比如密钥交换用标签"SM2_KA",加密用"SM2_ENC",这样即使共享秘密相同,派生出的密钥也完全不同。

好了,KDF这部分就讲到这里。记住一句话:KDF不是哈希,哈希不是KDF。理解了SM2 KDF的计数器+截断机制,你就能在项目中正确实现密钥派生,避免那些让人头疼的兼容性问题。