3、ChaCha20加密流程:密钥流生成(Block Function),明文与密钥流异或,计数器与Nonce的作用
好,咱们今天来啃ChaCha20加密流程这块硬骨头。说实话,很多初学者一上来就被“密钥流”、“Block Function”这些术语吓住了。其实没那么玄乎——你想想看,ChaCha20本质上就是个流密码,核心就两件事:造密钥流,然后跟明文异或。
我个人习惯把加密流程拆成三个步骤来看:
- 初始化状态矩阵——把密钥、Nonce、计数器塞进一个4×4的矩阵里。
- 执行Block Function——对这个矩阵做20轮(10轮双轮)的混乱操作,生成伪随机密钥流。
- 异或加密——拿密钥流跟明文逐字节异或,得到密文。
下面咱们一个一个拆开讲。
3.1 状态矩阵:一切从这里开始
ChaCha20的状态是一个4×4的矩阵,每个元素是32位(4字节)。总共16个元素,64字节。这个矩阵长这样:
| 列0 | 列1 | 列2 | 列3 |
|---|---|---|---|
| 常数0: "expa" | 常数1: "nd 3" | 常数2: "2-by" | 常数3: "te k" |
| 密钥0 | 密钥1 | 密钥2 | 密钥3 |
| 密钥4 | 密钥5 | 密钥6 | 密钥7 |
| 计数器 | Nonce0 | Nonce1 | Nonce2 |
嗯,这里要注意:常数部分是固定的,就是"expand 32-byte k"这个字符串的16进制表示。为什么是这串?因为ChaCha20的密钥正好32字节,这串字面意思就是“扩展32字节密钥”。
密钥部分:8个32位字,共256位。这是你的核心机密。
计数器部分:1个32位字。每加密一个64字节块,计数器就+1。
Nonce部分:3个32位字,共96位。这是每次加密必须不同的随机数。
核心要点:同一个密钥下,(Nonce, 计数器)这个组合必须全局唯一。否则,两个不同的明文块如果用了相同的密钥流,异或之后就能被攻击者还原出明文差异——我在项目中见过有人踩这个坑,后果很严重。
3.2 Block Function:核心的混乱引擎
Block Function说白了就是对状态矩阵做20轮(10轮双轮)的“搅拌”。每一轮包含4个Quarter Round(四分之一轮),每个Quarter Round操作4个状态字。
一个Quarter Round长这样(伪代码):
def quarter_round(a, b, c, d):
a += b; d ^= a; d <<<= 16
c += d; b ^= c; b <<<= 12
a += b; d ^= a; d <<<= 8
c += d; b ^= c; b <<<= 7
return a, b, c, d
你看,就是加、异或、循环左移这三个操作反复组合。没有复杂的数学,没有查表,纯位运算。这也是ChaCha20在软件实现上比AES快的原因之一。
每一轮(双轮)包含:
- 列轮:对矩阵的4列分别做Quarter Round
- 对角轮:对矩阵的4条对角线分别做Quarter Round
20轮之后,把初始状态矩阵和最终状态矩阵逐元素相加(模2^32),得到的就是这一块的密钥流。
个人经验:我曾经在实现时犯过一个低级错误——忘了做“初始状态与最终状态相加”这一步。结果生成的密钥流完全不对,排查了半天才发现。记住:Block Function的输出 = 初始状态 + 最终状态,不是直接拿最终状态当密钥流。
3.3 计数器与Nonce:为什么需要它们?
你可能会问:为什么不能直接用密钥生成无限长的密钥流?
答案很简单:安全。如果同一个密钥永远只生成同一套密钥流,那跟一次性密码本有什么区别?但一次性密码本要求密钥长度不小于明文长度,这在现实中不现实。
所以ChaCha20用了计数器模式:
- Nonce(96位):每次加密会话生成一个随机值。保证不同会话的密钥流不同。
- 计数器(32位):在同一会话内,每加密64字节块就+1。保证同一会话内不同块的密钥流不同。
组合起来就是:(密钥, Nonce, 块索引)唯一确定一个64字节的密钥流块。
我曾经踩过的坑:有一次做多线程加密,忘了给每个线程分配独立的Nonce,结果两个线程用了相同的Nonce和计数器,生成了完全相同的密钥流。加密两个不同的文件,其中一个密文被破解,另一个也跟着完蛋。所以记住:Nonce绝对不能重复使用,哪怕是一次。
3.4 明文与密钥流异或:最后一步
这一步其实最简单。拿到64字节的密钥流后,跟64字节的明文逐字节异或:
ciphertext[i] = plaintext[i] ^ keystream[i]
解密时完全一样:
plaintext[i] = ciphertext[i] ^ keystream[i]
因为异或操作是对称的。这也是流密码的通用特性。
如果明文长度不是64字节的整数倍,最后一块只需要取密钥流的前面部分,跟剩余明文异或即可。
3.5 整体流程图
下面这张SVG图把整个流程串起来了,建议你仔细看看:
3.6 代码骨架:Python实现核心逻辑
下面是一个简化版的ChaCha20加密流程,只展示核心逻辑:
def chacha20_encrypt(key, nonce, plaintext):
"""
key: 32字节 (256位)
nonce: 12字节 (96位)
plaintext: 任意长度
"""
ciphertext = b''
counter = 0
# 按64字节块处理
for offset in range(0, len(plaintext), 64):
# 1. 生成当前块的密钥流
keystream = chacha20_block(key, nonce, counter)
# 2. 取当前块明文
block = plaintext[offset:offset+64]
# 3. 异或加密
encrypted_block = bytes(
p ^ k for p, k in zip(block, keystream)
)
ciphertext += encrypted_block
# 4. 计数器+1
counter += 1
return ciphertext
你看,核心逻辑就这么几行。真正的复杂度在chacha20_block函数里——也就是我们前面讲的Block Function。
总结一下:ChaCha20加密流程就是状态初始化 → 20轮搅拌 → 生成密钥流 → 异或明文。计数器保证每个块的密钥流不同,Nonce保证每次会话的密钥流不同。记住这个框架,后面实现Poly1305时你会看到它们是怎么配合的。