ChaCha20 核心算法:初始化状态矩阵与轮函数
好,咱们今天正式进入 ChaCha20 的引擎部分。说实话,我第一次看这个算法时,觉得它像个魔方——16 个数字排成 4x4 的方阵,然后各种旋转、打乱、再组合。但等你真正理解了 Quarter Round 和 Double Round,你会发现它其实比 AES 要直观得多。
初始化状态矩阵:16 个 32-bit 字的秘密
ChaCha20 的状态矩阵,说白了就是一个 4x4 的表格,每个格子放一个 32 位的整数。我习惯把它想象成一个棋盘,只不过棋子是 0 和 1 组成的数字。
这个矩阵的布局是这样的:
| 行/列 | 列 0 | 列 1 | 列 2 | 列 3 |
|---|---|---|---|---|
| 行 0 | "expa" (0x61707865) | "nd 3" (0x3320646e) | "2-by" (0x79622d32) | "te k" (0x6b206574) |
| 行 1 | Key[0] | Key[1] | Key[2] | Key[3] |
| 行 2 | Key[4] | Key[5] | Key[6] | Key[7] |
| 行 3 | Counter (0) | Nonce[0] | Nonce[1] | Nonce[2] |
嗯,这里要注意:前四个字是固定的常量,它们拼起来就是 "expand 32-byte k"。我刚开始学的时候觉得这挺浪漫的——把一句话拆成四个 32 位整数,藏在算法的起点。后来在实际项目中,我遇到过有人不小心把常量写反了字节序,结果整个加密结果全乱套。所以,常量一定要按小端序来理解。
密钥占 8 个字(256 位),计数器占 1 个字,随机数占 3 个字(96 位)。你想想看,这 16 个字里,真正由用户控制的是密钥和随机数,常量和计数器都是固定的或递增的。
核心要点:初始化矩阵 = 4 个常量 + 8 个密钥字 + 1 个计数器 + 3 个随机数字。总共 16 个 32-bit 字,正好 512 位。
Quarter Round 函数详解:算法的原子操作
Quarter Round 是 ChaCha20 的最小操作单元。我管它叫"四分之一轮",因为它一次只处理矩阵中的四个字。说白了,就是给四个数字做一次"搅拌"。
它的输入是四个 32 位整数:a, b, c, d。输出也是四个 32 位整数,但内容已经被打乱了。具体步骤是这样的:
- a += b; d ^= a; d <<<= 16;
- c += d; b ^= c; b <<<= 12;
- a += b; d ^= a; d <<<= 8;
- c += d; b ^= c; b <<<= 7;
你看,每一步都是加法、异或、循环左移的组合。为什么这么设计?我个人的理解是:加法提供了非线性扩散,异或提供了可逆性,左移则把位信息打散到不同的位置。三者配合,效果远好于单独使用任何一种。
用 Python 实现 Quarter Round 非常直接:
def quarter_round(a, b, c, d):
a = (a + b) & 0xffffffff
d ^= a
d = ((d << 16) | (d >> 16)) & 0xffffffff
c = (c + d) & 0xffffffff
b ^= c
b = ((b << 12) | (b >> 20)) & 0xffffffff
a = (a + b) & 0xffffffff
d ^= a
d = ((d << 8) | (d >> 24)) & 0xffffffff
c = (c + d) & 0xffffffff
b ^= c
b = ((b << 7) | (b >> 25)) & 0xffffffff
return a, b, c, d
避坑指南:我曾经在实现时忘了做 & 0xffffffff 的截断,结果 Python 的整数自动扩展到了 64 位,导致后续的异或和移位全错了。记住,32 位运算必须手动截断,Python 不会帮你做这件事。
Double Round 与 10 轮迭代
一个 Double Round 包含 8 次 Quarter Round 调用。为什么叫"Double"?因为它先做 4 次"列轮",再做 4 次"对角线轮"。我刚开始觉得这名字挺绕的,后来发现其实就是把矩阵的行列和对角线各搅拌一遍。
列轮(Column Round):处理矩阵的 4 列,每列取 4 个字做 Quarter Round。
- 第 0 列: (0, 4, 8, 12)
- 第 1 列: (1, 5, 9, 13)
- 第 2 列: (2, 6, 10, 14)
- 第 3 列: (3, 7, 11, 15)
对角线轮(Diagonal Round):处理矩阵的 4 条对角线。
- 第 0 对角线: (0, 5, 10, 15)
- 第 1 对角线: (1, 6, 11, 12)
- 第 2 对角线: (2, 7, 8, 13)
- 第 3 对角线: (3, 4, 9, 14)
你发现没有?对角线轮其实就是在矩阵上"斜着走"。我习惯用一张图来理解这个过程:
ChaCha20 总共做 10 轮 Double Round,也就是 80 次 Quarter Round。为什么是 10 轮?这是设计者 Bernstein 经过安全分析后选定的。我记得在 RFC 8439 的讨论中,有人尝试过 8 轮和 12 轮,但 10 轮在安全性和性能之间取得了最好的平衡。
用 Python 实现 Double Round 和完整轮函数:
def double_round(state):
# 列轮
state[0], state[4], state[8], state[12] = quarter_round(state[0], state[4], state[8], state[12])
state[1], state[5], state[9], state[13] = quarter_round(state[1], state[5], state[9], state[13])
state[2], state[6], state[10], state[14] = quarter_round(state[2], state[6], state[10], state[14])
state[3], state[7], state[11], state[15] = quarter_round(state[3], state[7], state[11], state[15])
# 对角线轮
state[0], state[5], state[10], state[15] = quarter_round(state[0], state[5], state[10], state[15])
state[1], state[6], state[11], state[12] = quarter_round(state[1], state[6], state[11], state[12])
state[2], state[7], state[8], state[13] = quarter_round(state[2], state[7], state[8], state[13])
state[3], state[4], state[9], state[14] = quarter_round(state[3], state[4], state[9], state[14])
return state
def chacha20_block(key, counter, nonce):
# 初始化状态矩阵
state = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
key[0], key[1], key[2], key[3],
key[4], key[5], key[6], key[7],
counter, nonce[0], nonce[1], nonce[2]
]
working_state = state.copy()
# 10 轮 Double Round
for _ in range(10):
working_state = double_round(working_state)
# 与初始状态相加
result = [(working_state[i] + state[i]) & 0xffffffff for i in range(16)]
return result
重要提醒:10 轮迭代结束后,必须把工作状态与初始状态相加。这一步很多人会漏掉。如果不加,解密时无法恢复原始数据。我曾经在调试一个嵌入式项目时,就是因为忘了加这一步,折腾了整整一个下午。
为什么 ChaCha20 比 AES 更适合软件实现?
我个人觉得,ChaCha20 的设计哲学就是"为软件而生"。你看它的操作:加法、异或、移位,全是 CPU 最擅长的指令。没有 S 盒查表,没有 GF(2^8) 的复杂运算。在 ARM Cortex-M 这类没有硬件加速的芯片上,ChaCha20 的速度可以比 AES 快 3-5 倍。
而且,ChaCha20 的 Quarter Round 只依赖相邻的字,没有跨步长的数据依赖。这意味着你可以用 SIMD 指令一次处理多个 Quarter Round。我在 x86 上用 AVX2 做过优化,吞吐量能到 1.5 GB/s 以上。