4. 密钥对生成:私钥与公钥的数学关系、随机数生成器的安全性要求、密钥对验证

密钥对生成,是SM2算法的起点。说白了,你后面所有的签名、加密、密钥交换,都依赖这一步生成的密钥对是否安全。我见过不少项目,算法本身选得没问题,结果在密钥生成环节出了纰漏,整个安全体系直接崩塌。

这一章,我们就把密钥对生成这件事彻底讲透。从数学原理到工程实现,再到验证手段,一个环节都不放过。

4.1 私钥与公钥的数学关系

SM2基于椭圆曲线密码学。它的核心数学关系其实很简单:

  • 私钥:一个随机的大整数 \( d_A \),取值范围在 [1, n-2] 之间,其中 n 是椭圆曲线的阶。
  • 公钥:一个椭圆曲线上的点 \( P_A = d_A \cdot G \),其中 G 是椭圆曲线的基点。

这里的乘法不是普通的整数乘法,而是椭圆曲线上的标量乘法。你可以理解为:把基点 G 自己加自己,加 \( d_A \) 次。嗯,这个计算过程就是密钥生成的核心。

关键性质:从私钥计算公钥是容易的(正向计算),但从公钥反推私钥是困难的(椭圆曲线离散对数问题)。这就是SM2安全性的数学根基。

我个人习惯把这种关系称为「单向门」——你可以轻松推门进去,但想从里面把门拽开,几乎不可能。

4.2 随机数生成器的安全性要求

私钥是随机生成的。这个「随机」到底有多随机,直接决定了密钥的安全性。

我曾经参与过一个安全审计项目,对方用的随机数生成器是C语言的 rand() 函数。你想想看,这玩意儿能用来生成SM2私钥吗?显然不能。那一次审计结果,直接让对方重写了整个密钥管理系统。

4.2.1 随机数生成器的分类

类型 说明 适用场景
真随机数生成器 (TRNG) 基于物理熵源(如热噪声、量子效应) 密钥生成、安全令牌
伪随机数生成器 (PRNG) 基于算法和种子值 一般应用,但需密码学安全
密码学安全伪随机数生成器 (CSPRNG) 符合密码学安全要求的PRNG 密钥生成、签名、加密

SM2私钥生成,必须使用CSPRNG。这是底线,没有商量余地。

4.2.2 常见的CSPRNG实现

  • Linux/Unix:/dev/urandom 或 getrandom() 系统调用
  • Windows:CryptGenRandom() 或 BCryptGenRandom()
  • Go语言:crypto/rand 包
  • Python:os.urandom() 或 secrets 模块

警告:永远不要使用 math/rand(Go)、random(Python标准库)、rand()(C语言)等非密码学安全的随机数生成器来生成私钥。我曾经见过有人用 time.Now().UnixNano() 作为种子生成私钥——这种私钥,用暴力搜索几分钟就能破解。

4.3 密钥对生成流程

好了,理论讲完了,我们来看看实际怎么生成密钥对。我习惯把流程拆成三步:

  1. 生成随机私钥:使用CSPRNG生成一个256位的随机数,检查是否在有效范围内
  2. 计算公钥:执行椭圆曲线标量乘法 \( P_A = d_A \cdot G \)
  3. 验证密钥对:确认公钥在曲线上,且私钥与公钥满足数学关系

下面是一个Go语言的实现示例:

package main

import (
    "crypto/rand"
    "crypto/elliptic"
    "math/big"
    "fmt"
)

// SM2密钥对生成
func GenerateSM2KeyPair() (*big.Int, *SM2PublicKey, error) {
    // 1. 获取SM2曲线参数
    curve := GetSM2Curve() // 假设已实现SM2曲线
    
    // 2. 生成私钥
    // 私钥范围: [1, n-2]
    n := curve.Params().N
    privateKey, err := rand.Int(rand.Reader, new(big.Int).Sub(n, big.NewInt(2)))
    if err != nil {
        return nil, nil, fmt.Errorf("生成私钥失败: %v", err)
    }
    privateKey.Add(privateKey, big.NewInt(1)) // 确保私钥 >= 1
    
    // 3. 计算公钥
    x, y := curve.ScalarBaseMult(privateKey.Bytes())
    
    publicKey := &SM2PublicKey{
        Curve: curve,
        X:     x,
        Y:     y,
    }
    
    // 4. 验证密钥对
    if !VerifyKeyPair(privateKey, publicKey) {
        return nil, nil, fmt.Errorf("密钥对验证失败")
    }
    
    return privateKey, publicKey, nil
}

提示:在实际项目中,我建议把密钥生成和验证封装成一个原子操作。不要分开写,否则容易遗漏验证步骤。我见过一个项目,密钥生成后没有验证,结果公钥计算出了偏差,排查了整整两天才发现问题。

4.4 密钥对验证

密钥对生成后,必须验证。这不是可选项,是必选项。

验证包括两个层面:

  • 公钥合法性验证:公钥点是否在曲线上,是否不是无穷远点
  • 私钥与公钥匹配验证:重新计算 \( d_A \cdot G \) 是否等于公钥

4.4.1 公钥合法性验证

SM2公钥是一个椭圆曲线上的点。验证它是否合法,需要检查:

  1. 公钥坐标 \( (x, y) \) 是否在有限域 \( F_p \) 内
  2. 是否满足曲线方程 \( y^2 = x^3 + ax + b \mod p \)
  3. 公钥不是无穷远点
func ValidatePublicKey(pub *SM2PublicKey) bool {
    curve := pub.Curve
    p := curve.Params().P
    
    // 检查坐标范围
    if pub.X.Cmp(p) >= 0 || pub.Y.Cmp(p) >= 0 {
        return false
    }
    
    // 检查是否满足曲线方程
    // y^2 = x^3 + ax + b (mod p)
    lhs := new(big.Int).Mul(pub.Y, pub.Y)
    lhs.Mod(lhs, p)
    
    rhs := new(big.Int).Mul(pub.X, pub.X)
    rhs.Mul(rhs, pub.X)
    rhs.Add(rhs, curve.Params().B)
    rhs.Mod(rhs, p)
    
    return lhs.Cmp(rhs) == 0
}

4.4.2 私钥与公钥匹配验证

这一步其实很简单:用私钥重新计算公钥,看是否一致。

func VerifyKeyPair(privateKey *big.Int, publicKey *SM2PublicKey) bool {
    // 重新计算公钥
    x, y := publicKey.Curve.ScalarBaseMult(privateKey.Bytes())
    
    // 比较
    return x.Cmp(publicKey.X) == 0 && y.Cmp(publicKey.Y) == 0
}

避坑指南:我曾经遇到过一个情况,密钥对验证通过了,但签名验证总是失败。排查到最后发现,是私钥的字节序搞反了——大端序和小端序的问题。所以,验证时一定要确认字节序和编码方式与SM2标准一致。

4.5 密钥对生成的整体流程

下面这张图,把整个密钥对生成的流程串起来了。我建议你把它保存下来,每次写密钥生成代码时对照着看。

SM2密钥对生成流程图 SM2密钥对生成流程 物理熵源 / CSPRNG 生成随机私钥 dA 计算公钥 PA = dA · G 验证公钥合法性 验证通过? 失败:重新生成 输出密钥对 (dA, PA) 注意事项 • 私钥必须保密存储 • 公钥可以公开分发 • 验证步骤不可省略

4.6 工程实践中的注意事项

最后,分享几个我在实际项目中踩过的坑:

  • 私钥存储:永远不要以明文形式存储私钥。使用硬件安全模块(HSM)或密码学安全的密钥存储方案。
  • 随机数种子:如果使用PRNG,确保种子有足够的熵。我曾经见过一个嵌入式设备,每次启动时种子都一样——生成的私钥自然也一样。
  • 并发安全:在多线程环境下生成密钥对,确保随机数生成器是并发安全的。Go的 crypto/rand 是线程安全的,但有些实现不是。
  • 测试向量验证:实现完成后,用SM2标准中的测试向量验证。这一步能发现大多数实现错误。

个人经验:我习惯在密钥生成后,立即对密钥对做一次完整的签名-验证测试。用刚生成的私钥签一条消息,再用公钥验证签名。如果这一步能通过,基本可以确认密钥对生成没有问题。这个习惯帮我避免了好几次线上事故。