3. ChaCha20加密流程

好,咱们今天来啃ChaCha20加密流程这块硬骨头。说实话,很多初学者一上来就被“密钥流”、“Block Function”这些术语吓住了。其实没那么玄乎——你想想看,ChaCha20本质上就是个流密码,核心就两件事:造密钥流,然后跟明文异或。

我个人习惯把加密流程拆成三个步骤来看:

  1. 初始化状态矩阵——把密钥、Nonce、计数器塞进一个4×4的矩阵里。
  2. 执行Block Function——对这个矩阵做20轮(10轮双轮)的混乱操作,生成伪随机密钥流。
  3. 异或加密——拿密钥流跟明文逐字节异或,得到密文。

下面咱们一个一个拆开讲。

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图把整个流程串起来了,建议你仔细看看:

ChaCha20 加密流程总览 密钥 (256位) Nonce (96位) 计数器 (32位) 状态矩阵 (4×4 × 32位) 常数 | 密钥 | 密钥 | 计数器+Nonce 共 64 字节 Block Function (20轮 / 10轮双轮) 列轮 × 10 + 对角轮 × 10 Quarter Round: a+=b; d^=a; d<<<16; c+=d; b^=c; b<<<12; ... 密钥流 (64字节) ⊕ 明文 (64字节) 密文 (64字节) 解密流程完全一样:密文 ⊕ 密钥流 = 明文。计数器每处理完64字节自动+1。

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时你会看到它们是怎么配合的。