第二节:SM4对称加密算法原理与Go实现
咱们聊到国密,绕不开的一个核心就是 SM4 对称加密算法。说白了对标的就是国际上的 AES,当年为了替代 DES 搞出来的。我记得最早接触 SM4 是在一个政务系统项目里,客户明确要求必须用国密,那会儿我还在想这玩意儿跟 AES 差别大不大?实际用下来,结构上有相似之处,但细节上完全自成一派。
SM4 是什么?
SM4 是分组对称加密算法,分组长度 128 位,密钥长度也是 128 位。加密和解密用的同一把密钥,流程也几乎一样,只是轮密钥的使用顺序反过来。你想想看,这种设计意味着实现的时候加解密可以复用大量代码,非常省事。
我在项目里最深的体会是:SM4 的 S 盒和线性变换是它的灵魂。很多人直接用别人的库,遇到加解密对不上就开始怀疑人生。其实多半是 S 盒实现或者轮密钥调度搞错了。
算法核心参数
| 参数项 | 说明 |
|---|---|
| 分组大小 | 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 个轮密钥。过程是这样的:
- 初始化把密钥拆成 4 个 32 位字
- 和固定系统参数 FK 异或
- 经过固定轮迭代,使用 S 盒和 L' 变换
- 每轮输出一个 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
}
加解密流程
下面这个 SVG 图展示了 SM4 一次完整的加解密过程,从明文输入到密钥扩展再到轮函数输出:
Go 实现时的注意事项
- 字节序: SM4 使用大端序。如果你从网络传输拿到数据,可能需要转换字节序。我见过有人直接当小端去读,结果加密结果对不上。
- S 盒不要算,直接查表: 硬算费时还容易错。直接定义 [256]byte 就行。
- 轮密钥预计算: 加密多个块时,轮密钥只算一次。NewCipher 里做完密钥扩展,Encrypt 直接引用。
- 并发安全: 如果每个协程只用独立的 cipher 实例,不需要锁。同一个实例并发加解密会出问题。