第二节:SM4对称加密算法原理与Go实现

咱们聊到国密,绕不开的一个核心就是 SM4 对称加密算法。说白了对标的就是国际上的 AES,当年为了替代 DES 搞出来的。我记得最早接触 SM4 是在一个政务系统项目里,客户明确要求必须用国密,那会儿我还在想这玩意儿跟 AES 差别大不大?实际用下来,结构上有相似之处,但细节上完全自成一派。

SM4 是什么?

SM4 是分组对称加密算法,分组长度 128 位,密钥长度也是 128 位。加密和解密用的同一把密钥,流程也几乎一样,只是轮密钥的使用顺序反过来。你想想看,这种设计意味着实现的时候加解密可以复用大量代码,非常省事。

我在项目里最深的体会是:SM4 的 S 盒和线性变换是它的灵魂。很多人直接用别人的库,遇到加解密对不上就开始怀疑人生。其实多半是 S 盒实现或者轮密钥调度搞错了。

关键要点:SM4 不是 AES 的替代品,是独立设计的国密标准。它的密钥调度和加密过程使用相同的结构,只是轮密钥应用方向相反。

算法核心参数

参数项 说明
分组大小 128 位(16 字节)
密钥长度 128 位(16 字节)
轮数 32 轮
轮密钥长度 每轮 32 位
S 盒 8 位输入 8 位输出,256 个元素
加密结构 Feistel 网络结构

注意这个 Feistel 网络结构。这玩意儿的好处是什么?加密和解密的流程几乎对称,轮密钥反过来用就行。我在做硬件加速的时候,这个特性让 FPGA 实现变得特别简单。

SM4 的核心运算

其实 SM4 的轮函数就干三件事:轮密钥加、非线性变换 τ、线性变换 L。我把这几块拆开讲。

轮函数 F

每一轮,输入分成两半:左边不动,右边和轮密钥组合后经过变换再和左边异或。具体公式我就先不列了,你只需要知道每轮输入输出都是 128 位,轮密钥 32 位。

非线性变换 τ

这步就是 S 盒替换。SM4 的 S 盒是固定写死的,输入一个字节,输出一个字节。我在第一次自己实现时吃过亏——把 S 盒数组下标从 0 开始还是从 1 开始搞混了,结果加密出来的结果完全不对。后来我习惯先把 S 盒和标准测试向量做一遍验证,再做集成。

线性变换 L

τ 的输出经过一个线性变换。这个变换就是个循环左移后的异或操作。你想想看,其实就是把几个移位的版本异或到一起,增加扩散性。

  • 加密时:L(B) = B ⊕ (B <<< 2) ⊕ (B <<< 10) ⊕ (B <<< 18) ⊕ (B <<< 24)
  • 密钥调度时:L'(B) = B ⊕ (B <<< 13) ⊕ (B <<< 23)

看到没有,密钥调度的线性变换不一样,这是个易错点。我在 code review 时经常看到有人把 L 和 L' 用反,导致密钥扩展错误。

密钥扩展

SM4 从一把 128 位密钥生成 32 个轮密钥。过程是这样的:

  1. 初始化把密钥拆成 4 个 32 位字
  2. 和固定系统参数 FK 异或
  3. 经过固定轮迭代,使用 S 盒和 L' 变换
  4. 每轮输出一个 32 位的轮密钥

这里有个固定参数 FK 和 CK,都是标准里写死的。我建议你自己实现时不要手敲这些数据,直接拷贝标准附录,一个字节错了整个算法就废了。我曾经在生产环境遇到过从 PDF 里复制十六进制值时误把中间空格当有效字符的问题……

Go 语言实现

在 Go 里实现 SM4,我个人习惯遵循 crypto/cipher 标准接口。这样你的代码可以和 Go 生态无缝对接。

设计建议: 实现 cipher.Block 接口,这样 ECB、CBC、CTR 等分组模式直接拿来用。不要自己造轮子搞模式生成器。

下面是我常用的一段核心实现(简化版):

type Sm4Cipher struct {
    rk [32]uint32
}

func NewCipher(key []byte) (*Sm4Cipher, error) {
    if len(key) != 16 {
        return nil, errors.New("sm4: key must be 16 bytes")
    }
    c := new(Sm4Cipher)
    c.scheduleKey(key)
    return c, nil
}

func (c *Sm4Cipher) BlockSize() int { return 16 }

func (c *Sm4Cipher) Encrypt(dst, src []byte) {
    // 32 轮加密
    x := bytesToWords(src)
    for r := 0; r < 32; r++ {
        x = append(x[1:4], x[0]^roundFunc(x[1]^x[2]^x[3]^c.rk[r], sm4Sbox, sm4L))
    }
    wordsToBytes(dst, []uint32{x[3], x[2], x[1], x[0]})
}

func (c *Sm4Cipher) Decrypt(dst, src []byte) {
    x := bytesToWords(src)
    for r := 31; r >= 0; r-- {
        x = append(x[1:4], x[0]^roundFunc(x[1]^x[2]^x[3]^c.rk[r], sm4Sbox, sm4L))
    }
    wordsToBytes(dst, []uint32{x[3], x[2], x[1], x[0]})
}

看到加解密几乎一样了吧?唯一的区别是轮密钥下标走的方向反了。这样实现既简洁又不容易出错。

轮函数实现

func roundFunc(x uint32, sbox [256]byte, l func(uint32) uint32) uint32 {
    // 将32位拆成4个字节通过S盒
    var b [4]byte
    for i := 0; i < 4; i++ {
        b[i] = sbox[byte(x>>(uint(i*8)))]
    }
    // 拼回32位后做线性变换
    t := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
    return l(t)
}

这里面 S 盒查表要小心边界。我测试的时候习惯先用标准向量做单步调试,确认 S 盒输出和官方一致再往下走。

分组模式的使用

实现了 Block 接口后,用标准库模式很简单:

func EncryptCBC(key, iv, plaintext []byte) ([]byte, error) {
    block, _ := NewCipher(key)
    mode := cipher.NewCBCEncrypter(block, iv)
    padded := pkcs7pad(plaintext, block.BlockSize())
    ciphertext := make([]byte, len(padded))
    mode.CryptBlocks(ciphertext, padded)
    return ciphertext, nil
}
避坑: 分组模式需要自己做补齐,SM4 块大小是 16 字节。PKCS7 是最常用的方式。GCM 模式需要额外实现,因为 SM4 标准没有定义专门的认证加密模式,但你可以用 CTR + HMAC 组合。

加解密流程

下面这个 SVG 图展示了 SM4 一次完整的加解密过程,从明文输入到密钥扩展再到轮函数输出:

SM4 加解密流程 明文 128 位 密钥 128 位 密钥扩展 (32轮) 轮密钥 rk[0..31] 每轮取一个 32 位 32 轮迭代 含 τ 变换和 L 变换 密文 128 位 解密:逆序使用 rk

Go 实现时的注意事项

  • 字节序: SM4 使用大端序。如果你从网络传输拿到数据,可能需要转换字节序。我见过有人直接当小端去读,结果加密结果对不上。
  • S 盒不要算,直接查表: 硬算费时还容易错。直接定义 [256]byte 就行。
  • 轮密钥预计算: 加密多个块时,轮密钥只算一次。NewCipher 里做完密钥扩展,Encrypt 直接引用。
  • 并发安全: 如果每个协程只用独立的 cipher 实例,不需要锁。同一个实例并发加解密会出问题。
我的习惯: 每次实现 SM4 先把 S 盒和标准测试向量跑一遍。网上有官方发布的测试数据,输入明文和密钥,输出必须是确定的密文。能过这个测试,你的核心轮函数就基本没问题了。