mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
7232 字
20 分钟
对称加密:AES 与 ChaCha20
2026-03-09

当你用 BitLocker 加密硬盘时,或者用 HTTPS 访问银行网站时,背后都是对称加密在工作。它之所以无处不在,根本原因只有一个:。对称加密的速度比非对称加密快 100 到 1000 倍,是保护大量数据机密性的唯一实用选择。

密码学全景 中理解了密码学的三大分支。其中,对称加密离我们最近——你硬盘上的 BitLocker、浏览器里的 HTTPS、数据库里的透明加密,背后都是它。这一章就来拆解对称加密的内部构造。

一、对称加密基础#

1.1 什么是对称加密?#

对称加密使用同一个密钥进行加密和解密:

graph LR P["明文"] -->|"加密<br/>E(key, P)"| C["密文"] C -->|"解密<br/>D(key, C)"| P2["明文"] KEY["共享密钥<br/>(key)"] --> P KEY --> P2
维度对称加密非对称加密
密钥1 个(共享)2 个(公钥+私钥)
速度快(硬件加速)慢(100-1000x)
密钥分发需要安全信道公钥可公开
典型用途数据加密密钥交换、签名

1.2 两大类型#

类型原理代表算法特点
分组密码将数据分成固定大小的块加密AES需要工作模式
流密码逐字节加密ChaCha20无需填充

1.3 为什么对称加密这么快?#

对称加密的速度优势来自两个层面:

算法结构简单:AES 的核心操作是 4×4 字节矩阵上的替换、移位和混合,每个操作都可以用几条 CPU 指令完成。相比之下,RSA 需要对 2048 位大整数做模幂运算,一次运算就涉及数十万次乘法。

硬件直接加速:现代 x86 处理器内置了 AES-NI 指令集,AES 的一轮运算可以被编译为一条 aesenc 指令。这意味着 AES 不再是”软件模拟算法”,而是”CPU 原生支持的指令”,性能接近内存拷贝的速度。

1.4 对称加密的完整生命周期#

对称加密不只是”加密”和”解密”两个动作,它涉及密钥生成、分发、使用、轮换、销毁的完整生命周期:

flowchart LR KG["密钥生成<br/>CSPRNG"] --> KD["密钥分发<br/>KMS/DH/ECDH"] KD --> ENC["加密数据<br/>AES-GCM/ChaCha20"] ENC --> TX["传输密文<br/>+ nonce + tag"] TX --> DEC["解密数据<br/>验证 tag"] DEC --> KR["密钥轮换<br/>定期更换"] KR --> KG style KG fill:#e3f2fd,stroke:#1565c0 style ENC fill:#e8f5e9,stroke:#2e7d32 style DEC fill:#fff3e0,stroke:#e65100 style KR fill:#fce4ec,stroke:#c62828
Note

密钥分发是对称加密最大的痛点——双方必须安全地共享密钥。实际系统中通常用非对称加密(如 ECDH)来协商对称密钥,这就是 TLS 握手的核心逻辑:用慢的非对称加密交换密钥,然后用快的对称加密传输数据。

二、AES 分组密码#

2.1 AES 概述#

AES(Advanced Encryption Standard)是目前最广泛使用的对称加密算法,由比利时密码学家 Joan Daemen 和 Vincent Rijmen 设计(原名 Rijndael),2001 年被 NIST 选为美国联邦标准:

参数AES-128AES-192AES-256
密钥长度128 位192 位256 位
分组大小128 位128 位128 位
轮数101214
安全等级足够最高
Tip

AES-128 对于当前和可预见的未来已经足够安全。AES-256 的价值在于提供”后量子安全”——即使未来量子计算机能将暴力搜索加速到 √n(Grover 算法),AES-256 的 128 位安全裕度仍然足够。如果你不需要考虑量子威胁,AES-128 是性能和安全的最优平衡。

2.2 AES 加密流程#

graph TB PT["明文 (128-bit)"] --> XOR1["AddRoundKey<br/>(初始轮密钥加)"] XOR1 --> R1["Round 1-9/11/13<br/>SubBytes → ShiftRows → MixColumns → AddRoundKey"] R1 --> R2["Round 10/12/14<br/>SubBytes → ShiftRows → AddRoundKey"] R2 --> CT["密文 (128-bit)"] KEY["密钥"] --> KS["密钥扩展<br/>(Key Schedule)"] KS --> XOR1 KS --> R1 KS --> R2
操作说明目的
SubBytes字节替换(S-Box)非线性变换
ShiftRows行移位扩散
MixColumns列混合扩散
AddRoundKey轮密钥加引入密钥

AES 轮函数详解:一个 128-bit 的具体例子#

AES 将 128 位明文排列为 4×4 字节矩阵(State),每个字节称为一个”单元”。假设明文为 0x00112233445566778899aabbccddeeffSubBytes:将每个字节通过 S-Box 查表替换。S-Box 是一个 256 字节的查找表,设计上抵抗线性和差分密码分析。例如 0x00 → 0x630x11 → 0x82

ShiftRows:第 0 行不移动,第 1 行左移 1 字节,第 2 行左移 2 字节,第 3 行左移 3 字节。这确保了不同列之间的数据扩散: MixColumns:对每列进行 GF(2⁸) 上的矩阵乘法,使列内 4 个字节相互混合。这是 AES 扩散特性的核心来源——经过两轮 MixColumns,任何一个输入字节的变化都会影响到所有 16 个输出字节。

AddRoundKey:将 State 与当前轮密钥逐字节异或。这是唯一引入密钥信息的操作,也是 AES 安全性的根基。

2.3 AES 密钥扩展(Key Schedule)#

AES 将原始密钥扩展为多个轮密钥,每轮使用一个 128 位轮密钥。AES-128 的密钥扩展过程如下:

SBOX = [...] # AES S-Box 查找表
RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
def key_expansion(key_words):
W = list(key_words) # W[0..3] = 原始密钥
for i in range(4, 44):
temp = W[i - 1]
if i % 4 == 0:
temp = sub_word(rot_word(temp)) ^ (RCON[i // 4 - 1] << 24)
W.append(W[i - 4] ^ temp)
return W # 每 4 个字组成一个轮密钥
def rot_word(word):
"""循环左移一字节:[a,b,c,d] → [b,c,d,a]"""
return ((word << 8) | (word >> 24)) & 0xFFFFFFFF

密钥扩展的设计目标:原始密钥的微小变化(哪怕只翻转 1 位)都会引起轮密钥的剧烈变化——这被称为雪崩效应

2.4 AES-NI 硬件加速#

AES-NI(Advanced Encryption Standard New Instructions)是 Intel 从 Westmere 架构(2010 年)开始引入的指令集,AMD 从 Bulldozer 架构开始支持。它包含 6 条指令:

指令功能说明
aesencAES 轮加密执行 SubBytes + ShiftRows + MixColumns + AddRoundKey
aesenclastAES 最后一轮加密执行 SubBytes + ShiftRows + AddRoundKey(无 MixColumns)
aesdecAES 轮解密逆轮操作
aesdeclastAES 最后一轮解密逆最后一轮操作
aeskeygenassist辅助密钥扩展生成轮密钥
aesimc逆 MixColumns解密预处理
// AES-NI 加密一轮的伪代码(实际是单条指令)
// 一条 aesenc 指令完成 4 个操作的组合
__m128i aesenc(__m128i state, __m128i round_key) {
// 硬件内部完成:
// 1. SubBytes(S-Box 查表,硬件实现)
// 2. ShiftRows(字节重排,硬件连线)
// 3. MixColumns(GF(2^8) 矩阵乘,硬件实现)
// 4. AddRoundKey(XOR,一条指令)
return result;
}

AES-NI 的关键安全意义不仅是性能——它消除了软件实现的侧信道风险。软件实现的 AES 查表操作可能泄露时序信息(缓存命中/未命中),而硬件指令是常数时间的。

2.5 AES 工作模式#

模式全称并行加密并行解密随机访问认证推荐
ECBElectronic Codebook不安全
CBCCipher Block Chaining需配合 HMAC
CTRCounter需配合 HMAC
GCMGalois/Counter Mode推荐
CCMCounter with CBC-MAC可选
// AES-GCM 加密(推荐)
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey key = keyGen.generateKey();
// 加密
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv); // 128-bit tag
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] ciphertext = cipher.doFinal(plaintext);
// 解密
cipher.init(Cipher.DECRYPT_MODE, key, spec);
byte[] decrypted = cipher.doFinal(ciphertext);

2.6 ECB 模式的问题#

ECB 模式对相同的明文块产生相同的密文块,无法隐藏数据模式: 著名的”ECB 企鹅”实验完美展示了这个问题:将 Tux(Linux 吉祥物企鹅)的位图用 ECB 模式加密后,虽然每个像素的颜色都变了,但企鹅的轮廓仍然清晰可见——因为图像中大量相同颜色的区域产生了相同的密文块。

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def ecb_encrypt_image(key, image_data):
description: " """ECB 模式加密图像——轮廓仍然可见!"""
cipher = Cipher(algorithms.AES(key), modes.ECB())
encryptor = cipher.encryptor()
return encryptor.update(image_data) + encryptor.finalize()
def cbc_encrypt_image(key, iv, image_data):
description: " """CBC 模式加密图像——轮廓完全消失"""
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
return encryptor.update(image_data) + encryptor.finalize()
Warning

永远不要使用 ECB 模式——它无法隐藏数据模式,著名的”ECB 企鹅”图片就是证明。始终使用 GCM 模式(推荐)或 CBC+HMAC 模式。

三、ChaCha20 流密码#

3.1 ChaCha20 概述#

ChaCha20 是 Daniel Bernstein 设计的流密码,配合 Poly1305 MAC 构成 ChaCha20-Poly1305 AEAD。它源自 Salsa20,通过调整四分之一轮(quarter-round)函数中的操作顺序来改善扩散:

维度AES-GCMChaCha20-Poly1305
类型分组密码 + GHASH流密码 + MAC
硬件加速AES-NI(x86)无(但软件实现快)
移动端慢(无 AES-NI)
nonce 长度96 位96 位
nonce 重用灾难性灾难性
常数时间需要 AES-NI天然常数时间

3.2 ChaCha20 四分之一轮(Quarter-Round)#

ChaCha20 的核心是 quarter-round 操作,它对 4 个 32 位字进行混合。这是 ChaCha20 安全性和效率的根基:

def quarter_round(a, b, c, d):
"""ChaCha20 四分之一轮操作"""
a = (a + b) & 0xFFFFFFFF; d ^= a; d = rotl32(d, 16)
c = (c + d) & 0xFFFFFFFF; b ^= c; b = rotl32(b, 12)
a = (a + b) & 0xFFFFFFFF; d ^= a; d = rotl32(d, 8)
c = (c + d) & 0xFFFFFFFF; b ^= c; b = rotl32(b, 7)
return a, b, c, d
def rotl32(v, n):
"""32 位循环左移"""
return ((v << n) | (v >> (32 - n))) & 0xFFFFFFFF

每次 quarter-round 包含 4 次加法、4 次 XOR 和 4 次循环左移——全部是 CPU 原生支持的运算,不需要查表,因此天然抵抗缓存时序攻击。

ChaCha20 的 512-bit 状态由 16 个 32 位字组成,排列为 4×4 矩阵:

┌──────────┬──────────┬──────────┬──────────┐
│ constant │ constant │ constant │ constant │
│ constant │ constant │ constant │ constant │
│ key │ key │ key │ key │
│ key │ key │ key │ key │
│ counter │ nonce │ nonce │ nonce │
└──────────┴──────────┴──────────┴──────────┘

每轮对矩阵的行和列交替执行 quarter-round 操作,20 轮后输出密钥流。

3.3 为什么 ChaCha20 在移动端更快#

移动端处理器(ARM Cortex-A 系列)通常没有 AES 硬件加速(ARMv8 的 AES 扩展直到 Cortex-A35 才普及),而 ChaCha20 的软件实现在 ARM 上特别高效:

因素AES 软件实现ChaCha20 软件实现
核心操作S-Box 查表(缓存不友好)加法 + XOR + 移位(寄存器操作)
查表需求需要 4KB+ 查找表不需要任何查找表
缓存影响表可能在缓存中缺失无缓存依赖
分支无分支无分支
ARM 优化需要位切片技巧原生 32 位运算,NEON 可并行

在无 AES-NI 的 ARMv7 设备上,ChaCha20-Poly1305 的吞吐量可达 AES-128-GCM 软件实现的 3-5 倍。

3.4 ChaCha20 的设计哲学#

Daniel Bernstein 的设计哲学可以概括为”简单即安全”:

  1. 不依赖查表:消除了缓存时序攻击面
  2. 不依赖硬件特性:在任何平台上都有可预测的性能
  3. 轮数充足:20 轮远超已知的攻击上限(目前最佳攻击只能破 7 轮)
  4. 常数时间:所有操作都是寄存器级运算,执行时间与密钥无关

这与 AES 的设计哲学形成对比——AES 依赖 S-Box 的代数结构提供非线性,而 ChaCha20 依赖加法-旋转-异或(ARX)结构。ARX 的优势是可证明的常数时间执行。

3.5 ChaCha20-Poly1305 加密流程#

flowchart LR KEY["256-bit 密钥"] --> CH["ChaCha20<br/>生成密钥流"] NONCE["96-bit nonce"] --> CH CH --> KS["密钥流"] KS --> XOR["XOR 明文"] PT["明文"] --> XOR XOR --> CT["密文"] CH --> OTK["Poly1305<br/>一次性密钥"] OTK --> POLY["Poly1305 MAC"] CT --> POLY AAD["关联数据<br/>(AAD)"] --> POLY POLY --> TAG["认证标签<br/>(128-bit)"] style CH fill:#e3f2fd,stroke:#1565c0 style POLY fill:#e8f5e9,stroke:#2e7d32 style TAG fill:#fff3e0,stroke:#e65100

3.6 ChaCha20-Poly1305 实现#

// Java: ChaCha20-Poly1305 (JDK 11+)
KeyGenerator keyGen = KeyGenerator.getInstance("ChaCha20-Poly1305");
keyGen.init(256);
SecretKey key = keyGen.generateKey();
// 加密
Cipher cipher = Cipher.getInstance("ChaCha20-Poly1305/None/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] ciphertext = cipher.doFinal(plaintext);
// 解密
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(cipher.getIV()));
byte[] decrypted = cipher.doFinal(ciphertext);
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
key = ChaCha20Poly1305.generate_key()
chacha = ChaCha20Poly1305(key)
nonce = os.urandom(12)
ciphertext = chacha.encrypt(nonce, b"Hello, World!", None)
plaintext = chacha.decrypt(nonce, ciphertext, None)
// Go: ChaCha20-Poly1305
package main
import (
"crypto/cipher"
"golang.org/x/crypto/chacha20poly1305"
)
func encrypt(key, nonce, plaintext, aad []byte) []byte {
aead, _ := chacha20poly1305.New(key)
return aead.Seal(nil, nonce, plaintext, aad)
}
func decrypt(key, nonce, ciphertext, aad []byte) ([]byte, error) {
aead, _ := chacha20poly1305.New(key)
return aead.Open(nil, nonce, ciphertext, aad)
}

3.7 AES-GCM vs ChaCha20-Poly1305 选择#

场景推荐原因
x86 服务器AES-GCMAES-NI 硬件加速
ARM 移动端ChaCha20-Poly1305软件实现更快
无 AES-NIChaCha20-Poly1305避免 AES 软件实现的侧信道
TLS 1.3两者都支持浏览器自动选择
通用推荐AES-256-GCM生态最成熟

四、AEAD 认证加密#

4.1 为什么需要 AEAD?#

传统加密只保证机密性,不保证完整性——密文可以被篡改:

sequenceDiagram participant A as Alice participant E as Eve (攻击者) participant B as Bob A->>E: 密文 (无认证) E->>E: 篡改密文 E->>B: 篡改后的密文 B->>B: 解密得到错误数据! Note over B: 不知道数据被篡改

AEAD(Authenticated Encryption with Associated Data)同时提供加密和认证:

AEAD 方案加密认证关联数据
AES-GCMAES-CTRGHASH支持
ChaCha20-Poly1305ChaCha20Poly1305支持
AES-CCMAES-CTRCBC-MAC支持

4.2 AEAD 的三种构造方式#

将加密和认证组合起来,有三种方式,安全性差异巨大:

构造方式流程安全性代表协议
Encrypt-then-MAC先加密,再对密文计算 MAC最安全IPsec
MAC-then-Encrypt先计算 MAC,再加密明文+MAC有风险TLS 1.0/1.1
Encrypt-and-MAC同时加密明文和计算 MAC有风险SSH

为什么 Encrypt-then-MAC 最安全?#

Encrypt-then-MAC 对密文计算认证标签,解密时先验证标签再解密。这意味着:

  1. 篡改密文会被立即发现:验证在解密之前,攻击者无法让接收方处理篡改后的明文
  2. 不泄露明文信息:MAC 是对密文计算的,即使 MAC 验证失败也不会暴露明文结构
  3. Padding Oracle 攻击无效:因为解密前必须先通过认证,攻击者无法利用填充错误消息

MAC-then-Encrypt 的问题在于:接收方必须先解密才能验证 MAC。如果解密过程中产生了不同的错误消息(“填充错误” vs “MAC 错误”),攻击者可以利用这个差异进行 Padding Oracle 攻击——这正是 TLS 1.0/1.1 的漏洞根源。

Encrypt-and-MAC 的问题更微妙:MAC 是对明文计算的,如果相同的明文产生相同的 MAC,攻击者可以判断两条消息是否相同。SSH 协议使用了这种方式,但因为 HMAC 的随机化输出,实际风险较低。

Warning

如果你需要自己组合加密和认证(不推荐),务必使用 Encrypt-then-MAC。更好的选择是直接使用 AEAD 模式(GCM 或 ChaCha20-Poly1305),它们在内部已经采用了安全的构造方式。

4.3 关联数据(AAD)#

AAD(Additional Authenticated Data)是不加密但需要认证的数据。这在很多场景下非常有用——你希望某些数据可见但不可篡改:

AAD 的真实应用:JWT 加密#

在 JWE(JSON Web Encryption)中,JWT 的头部(Header)就是 AAD 的典型应用:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, base64, json
header = base64.urlsafe_b64encode(
json.dumps({"alg": "dir", "enc": "A256GCM"}).encode()
)
payload = json.dumps({"sub": "user123", "exp": 1735689600}).encode()
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, payload, header)

如果攻击者修改了 Header 中的算法字段,解密时 AAD 验证会失败——这防止了算法混淆攻击。

五、填充攻击#

5.1 Padding Oracle 攻击#

CBC 模式使用填充(PKCS#7),攻击者可以通过错误消息推断明文:

sequenceDiagram participant A as 攻击者 participant S as 服务器 A->>S: 篡改的密文 S->>S: 解密 + 验证填充 alt 填充无效 S->>A: "Padding Error" else 填充有效 S->>A: "MAC Error" end Note over A: 通过不同的错误消息<br/>逐字节推断明文

PKCS#7 填充规则:如果明文最后一块差 n 字节,就填充 n 个值为 n 的字节。例如差 3 字节就填充 0x03 0x03 0x03

攻击的核心利用了 CBC 解密的一个数学性质: 其中 P[i] 是明文块,D(C[i]) 是 AES 解密结果,C[i-1] 是前一个密文块。攻击者可以修改 C[i-1] 来控制 P[i] 的值。

逐字节恢复明文的过程

  1. 攻击者截获密文 [C[i-1]] [C[i]]
  2. 构造修改后的密文 [C'[i-1]] [C[i]],其中 C'[i-1] 的最后一个字节从 0 开始尝试
  3. 发送给服务器,观察错误消息:
    • 如果返回”Padding Error”:说明解密后最后一个字节不是 0x01,继续尝试
    • 如果返回”MAC Error”或其他非填充错误:说明解密后最后一个字节是 0x01
  4. 此时 D(C[i]) 的最后一个字节 = C'[i-1] 的最后一个字节 XOR 0x01
  5. 原始明文 P[i] 的最后一个字节 = D(C[i]) 的最后一个字节 XOR C[i-1] 的最后一个字节
  6. 重复以上过程,逐字节恢复整个明文块
def padding_oracle_attack(ciphertext_block, prev_block, oracle):
"""
利用 Padding Oracle 逐字节恢复明文
oracle: 函数,返回 True 表示填充有效,False 表示填充无效
"""
intermediate = bytearray(16) # D(C[i]) 的值
for byte_pos in range(15, -1, -1): # 从最后一个字节开始
padding_value = 16 - byte_pos # 目标填充值
crafted = bytearray(16)
for k in range(byte_pos + 1, 16):
crafted[k] = intermediate[k] ^ padding_value
for guess in range(256):
crafted[byte_pos] = guess
if oracle(bytes(crafted), ciphertext_block):
intermediate[byte_pos] = guess ^ padding_value
break
plaintext = bytes(a ^ b for a, b in zip(intermediate, prev_block))
return plaintext
Note

Padding Oracle 攻击是真实世界中危害极大的攻击——它可以将 CBC 模式的密文完全解密。最简单的防御是使用 AEAD 模式(GCM 或 ChaCha20-Poly1305),它们不需要填充。

5.2 POODLE 攻击#

2014 年发现的 POODLE(Padding Oracle On Downgraded Legacy Encryption)攻击是 Padding Oracle 的另一个经典案例:

攻击背景:TLS 协议支持版本协商——客户端和服务器协商最高共同支持的版本。攻击者可以强制将 TLS 降级到 SSL 3.0。

SSL 3.0 的致命缺陷:SSL 3.0 使用 CBC 模式,但填充字节的内容是不确定的——只要求最后一个字节等于填充长度,前面的填充字节可以是任意值。这意味着:

  1. 攻击者可以构造一个密文块,使得解密后的最后一个字节恰好是 0x00(长度为 1 的填充)
  2. 服务器不会检查填充内容,只检查最后一个字节
  3. 攻击者利用这个 oracle 逐字节恢复 Cookie/密码
攻击目标利用方式影响
Padding OracleASP.NET / Java 服务器区分填充错误和 MAC 错误完全解密 CBC 密文
POODLESSL 3.0填充内容不验证 + 协议降级窃取浏览器 Cookie
Lucky 13TLS CBC 模式MAC 验证时间差异部分明文恢复

5.3 如何检测和防御填充攻击#

from flask import Flask, jsonify
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
app = Flask(__name__)
@app.route("/decrypt", methods=["POST"])
def decrypt_data():
try:
plaintext = aesgcm.decrypt(nonce, ciphertext, aad)
return jsonify({"status": "ok", "data": plaintext.decode()})
except Exception:
return jsonify({"status": "error", "message": "Decryption failed"}), 400
import hmac
def constant_time_compare(a: bytes, b: bytes) -> bool:
"""常数时间比较,防止时序攻击"""
return hmac.compare_digest(a, b)
防御措施说明优先级
使用 AEADGCM/ChaCha20-Poly1305 无需填充最推荐
统一错误消息不区分填充错误和 MAC 错误必须
先验证 MACEncrypt-then-MAC 而非 MAC-then-Encrypt必须
常数时间比较防止时序攻击必须
禁用 SSL 3.0防止 POODLE 降级攻击必须

六、密钥管理#

6.1 密钥生成#

// Go: 安全的密钥生成
package main
import (
"crypto/aes"
"crypto/rand"
"io"
)
func generateKey() ([]byte, error) {
key := make([]byte, aes.KeySize) // 32 bytes for AES-256
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
return key, nil
}

6.2 密钥存储#

方式安全性适用场景
硬编码极不安全永远不要
配置文件需加密开发环境
环境变量可泄露容器部署
KMS推荐生产环境
HSM最高安全金融场景

6.3 密钥轮换策略#

密钥轮换是安全运维的核心实践——即使密钥没有泄露,定期轮换也能限制密钥泄露的影响范围:

策略轮换频率适用场景实现复杂度
基于时间30/90/365 天合规要求(PCI-DSS)
基于数据量每加密 N GB大数据场景
基于事件密钥疑似泄露应急响应
自动轮换每次加密新密钥前向安全
class KeyManager:
def __init__(self):
self.keys = {} # version -> key
self.current_version = None
def rotate_key(self, new_key: bytes):
"""轮换密钥:新密钥成为当前版本"""
version = len(self.keys) + 1
self.keys[version] = new_key
self.current_version = version
return version
def encrypt(self, plaintext: bytes) -> tuple:
"""用当前密钥加密,密文包含版本号"""
nonce = os.urandom(12)
ciphertext = AESGCM(self.keys[self.current_version]).encrypt(
nonce, plaintext, None
)
return (self.current_version, nonce, ciphertext)
def decrypt(self, version: int, nonce: bytes, ciphertext: bytes) -> bytes:
"""用对应版本的密钥解密"""
return AESGCM(self.keys[version]).decrypt(nonce, ciphertext, None)

6.4 信封加密(Envelope Encryption)#

直接用主密钥加密所有数据是不现实的——主密钥的使用频率过高,泄露风险大。信封加密通过”密钥加密密钥”的层次结构解决这个问题:

flowchart TB MK["主密钥<br/>(Master Key)<br/>存储在 KMS"] DEK1["数据加密密钥 1<br/>(DEK)"] DEK2["数据加密密钥 2<br/>(DEK)"] DEK3["数据加密密钥 3<br/>(DEK)"] D1["数据 1"] --> DEK1 D2["数据 2"] --> DEK2 D3["数据 3"] --> DEK3 MK -->|"加密 DEK"| EDEK1["加密后的 DEK 1"] MK -->|"加密 DEK"| EDEK2["加密后的 DEK 2"] MK -->|"加密 DEK"| EDEK3["加密后的 DEK 3"] style MK fill:#fce4ec,stroke:#c62828 style DEK1 fill:#e3f2fd,stroke:#1565c0 style DEK2 fill:#e3f2fd,stroke:#1565c0 style DEK3 fill:#e3f2fd,stroke:#1565c0

信封加密的工作流程:

  1. 生成 DEK:每次加密数据时,生成一个新的数据加密密钥(DEK)
  2. 加密数据:用 DEK 加密实际数据
  3. 加密 DEK:用主密钥(存储在 KMS 中)加密 DEK
  4. 存储:将加密后的 DEK 和密文一起存储
  5. 解密:先从 KMS 获取主密钥解密 DEK,再用 DEK 解密数据
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kms import KMSClient # 伪代码
import os
def envelope_encrypt(plaintext: bytes, master_key_id: str) -> dict:
"""信封加密"""
dek = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
aesgcm = AESGCM(dek)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
encrypted_dek = kms_encrypt(master_key_id, dek)
return {
"encrypted_dek": encrypted_dek,
"nonce": nonce,
"ciphertext": ciphertext
}

信封加密的优势:主密钥从不直接接触数据,且可以独立轮换——轮换主密钥只需要重新加密 DEK,不需要重新加密所有数据。

6.5 IV/Nonce 管理#

规则说明
绝不重用GCM 模式下 nonce 重用会导致密钥恢复
随机生成每次加密使用新的随机 nonce
长度正确GCM: 96 位, ChaCha20: 96 位
随密文传输nonce 不需要保密,但需要唯一

Nonce 重用的灾难性后果#

AES-GCM 的 nonce 重用为什么是灾难性的?因为 GCM 本质上是 CTR 模式 + GHASH 认证。如果两次加密使用相同的 key 和 nonce:
C1 = P1 XOR KeyStream
C2 = P2 XOR KeyStream
C1 XOR C2 = P1 XOR P2

攻击者只需将两个密文 XOR,就消除了密钥流,得到两个明文的 XOR。如果攻击者知道其中一个明文(例如 HTTP 请求头),就可以恢复另一个明文。更严重的是,nonce 重用还会破坏 GHASH 的认证安全性,允许攻击者伪造有效的认证标签。

Warning

AES-GCM 的 nonce 重用是灾难性的——如果用同一个 key 和 nonce 加密两条消息,攻击者可以恢复密钥并解密所有消息。务必确保 nonce 的唯一性。

七、对称加密性能优化#

7.1 AES-GCM vs ChaCha20-Poly1305 吞吐量对比#

不同平台上的性能差异显著,选错算法可能让吞吐量差出数量级:

平台AES-128-GCMAES-256-GCMChaCha20-Poly1305最优选择
x86 (AES-NI)~45 Gbps~35 Gbps~8 GbpsAES-GCM
x86 (无 AES-NI)~1.5 Gbps~1.2 Gbps~8 GbpsChaCha20
ARM Cortex-A53~0.8 Gbps~0.6 Gbps~2.5 GbpsChaCha20
ARM Cortex-A76 (AES)~12 Gbps~10 Gbps~5 GbpsAES-GCM
Apple M1/M2~40 Gbps~35 Gbps~10 GbpsAES-GCM

以上数据为近似值,实际性能取决于 CPU 频率、内存带宽和实现质量。

7.2 批量加密 vs 流式加密#

维度批量加密流式加密
数据要求全部数据在内存中可以逐块处理
内存占用与数据量成正比常数空间
适用场景小文件、数据库字段大文件、网络流
AES-GCM一次性 Seal()stream.Seal() 分块
ChaCha20一次性加密天然支持流式
// Go: 流式 AES-GCM 加密大文件
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"io"
)
func encryptStream(key []byte, src io.Reader, dst io.Writer) error {
block, _ := aes.NewCipher(key)
nonce := make([]byte, 12)
rand.Read(nonce)
dst.Write(nonce) // nonce 明文传输
gcm, _ := cipher.NewGCM(block)
// 注意:标准库的 GCM 不直接支持流式
// 生产环境应使用 cipher.StreamWriter 或专用库
// 这里展示分块读取的思路
buf := make([]byte, 4096)
for {
n, err := src.Read(buf)
if n > 0 {
ciphertext := gcm.Seal(nil, nonce, buf[:n], nil)
dst.Write(ciphertext)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}

7.3 性能优化实践#

import os
import time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
def benchmark(aead_cls, key_size, data_size=10 * 1024 * 1024, iterations=10):
"""基准测试:加密 10MB 数据的吞吐量"""
key = aead_cls.generate_key(bit_length=key_size) if key_size else aead_cls.generate_key()
aead = aead_cls(key)
data = os.urandom(data_size)
nonce = os.urandom(12)
start = time.perf_counter()
for _ in range(iterations):
ciphertext = aead.encrypt(nonce, data, None)
elapsed = time.perf_counter() - start
throughput = (data_size * iterations) / elapsed / 1e9 # GB/s
return throughput
aes_throughput = benchmark(AESGCM, 256)
chacha_throughput = benchmark(ChaCha20Poly1305, None)
print(f"AES-256-GCM: {aes_throughput:.2f} GB/s")
print(f"ChaCha20-Poly1305: {chacha_throughput:.2f} GB/s")

七·附、实践:从凯撒密码到 AES-GCM#

密码学不是一天建成的。两千年前,凯撒大帝用字母移位传递军令;一千年后,阿拉伯学者发明频率分析将其破解;再往后,Vigenère 密码用多表替换抵抗频率分析,Enigma 用机电转子实现每日更换的替换表,DES 用 56 位密钥开启了计算机加密时代,最终 AES 以 128 位分组和 256 位密钥成为当今世界的加密基石。

这条演进脉络有一个清晰的主线:密钥空间的指数级增长。凯撒密码的密钥空间只有 26,暴力破解只需 26 次尝试;Vigenère 密码的密钥空间增长到 26^n(n 为密钥长度);AES-256 的密钥空间是 2^256,即使动用全世界的算力也无法穷举。但密钥空间只是安全的必要条件,不是充分条件——算法结构、工作模式、密钥管理同样重要。接下来,用代码亲手走过这条演进之路。

附.1 前置知识#

  • Python 3.8+ 环境
  • cryptography 库(pip install cryptography
  • 理解本章前七节的理论内容

附.2 凯撒密码:密码学的起点#

凯撒密码是最简单的替换密码——将字母表中的每个字母向后移动固定位数。它的密钥空间只有 26,暴力破解毫无难度,但它揭示了密码学的基本模型:加密是可逆变换,密钥控制变换参数

def caesar_encrypt(text, shift):
result = []
for ch in text:
if ch.isalpha():
base = ord('A') if ch.isupper() else ord('a')
result.append(chr((ord(ch) - base + shift) % 26 + base))
else:
result.append(ch)
return ''.join(result)
def caesar_decrypt(text, shift):
return caesar_encrypt(text, -shift)
plaintext = 'ATTACK AT DAWN'
shift = 3
ciphertext = caesar_encrypt(plaintext, shift)
decrypted = caesar_decrypt(ciphertext, shift)
print(f'明文: {plaintext}')
print(f'密钥: 偏移 {shift}')
print(f'加密后: {ciphertext}')
print(f'解密后: {decrypted}')
print()
# 暴力破解
print('--- 暴力破解(尝试所有 26 种偏移)---')
for s in range(26):
guess = caesar_encrypt(ciphertext, -s)
marker = ' <-- 正确' if s == shift else ''
print(f' 偏移 {s:2d}: {guess}{marker}')
$ python3 caesar.py
明文: ATTACK AT DAWN
密钥: 偏移 3
加密后: DWWDFN DW GDZQ
解密后: ATTACK AT DAWN
--- 暴力破解(尝试所有 26 种偏移)---
偏移 0: DWWDFN DW GDZQ
偏移 1: CVVCEM CV FCYP
偏移 2: BUUBDL BU EBXO
偏移 3: ATTACK AT DAWN <-- 正确
偏移 4: ZSSZBJ ZS CZVM
偏移 5: YRRYAI YR BYUL
偏移 6: XQQXZH XQ AXTK
偏移 7: WPPWYG WP ZWSJ
偏移 8: VOOVXF VO YVRI
偏移 9: UNNUWE UN XUQH
偏移 10: TMMTVD TM WTPG
偏移 11: SLLSUC SL VSOF
偏移 12: RKKRTB RK URNE
偏移 13: QJJQSA QJ TQMD
偏移 14: PIIPRZ PI SPLC
偏移 15: OHHOQY OH ROKB
偏移 16: NGGNPX NG QNJA
偏移 17: MFFMOW MF PMIZ
偏移 18: LEELNV LE OLHY
偏移 19: KDDKMU KD NKGX
偏移 20: JCCJLT JC MJFW
偏移 21: IBBIKS IB LIEV
偏移 22: HAAHJR HA KHDU
偏移 23: GZZGIQ GZ JGCT
偏移 24: FYYFHP FY IFBS
偏移 25: EXXEGO EX HEAR

凯撒密码的根本缺陷在于:每个字母的加密结果只取决于它本身和密钥,与上下文无关。这意味着相同的字母总是被替换为相同的密文字母——频率分析正是利用了这一点。9 世纪的阿拉伯学者 Al-Kindi 最早发现了这个弱点:统计密文中各字母的出现频率,与明文语言的频率分布对比,就能推断出密钥。

注:凯撒密码虽然简单,但它包含了密码学的基本要素——明文、密文、密钥、加密算法、解密算法。所有后续的加密方案都是在这个框架上的演进。

附.3 XOR 流密码:对称加密的数学本质#

凯撒密码的替换操作在计算机时代被一个更优雅的运算取代:异或(XOR)。XOR 之所以成为对称加密的数学基石,是因为它有一个关键性质——自反性a ⊕ k ⊕ k = a。这意味着用同一个密钥做两次 XOR 就能还原明文,加密和解密是同一个操作。

import os
def xor_stream_cipher(data, key):
"""XOR 流密码:将数据与密钥逐字节异或"""
key_stream = (key * ((len(data) // len(key)) + 1))[:len(data)]
return bytes(a ^ b for a, b in zip(data, key_stream))
plaintext = b'Hello, Symmetric Encryption!'
key = os.urandom(8) # 8 字节随机密钥
ciphertext = xor_stream_cipher(plaintext, key)
decrypted = xor_stream_cipher(ciphertext, key)
print(f'明文: {plaintext.decode()}')
print(f'密钥: {key.hex()}')
print(f'加密后: {ciphertext.hex()}')
print(f'解密后: {decrypted.decode()}')
print()
# XOR 的数学本质
print('--- XOR 的数学本质 ---')
a = plaintext[0]
k = key[0]
enc = a ^ k
dec = enc ^ k
print(f' 明文字节 a = 0x{a:02x} ({chr(a)})')
print(f' 密钥字节 k = 0x{k:02x}')
print(f' 加密 a⊕k = 0x{enc:02x}')
print(f' 解密 (a⊕k)⊕k = 0x{dec:02x} ({chr(dec)})')
print(f' 验证: a⊕k⊕k = a ')
print()
# 密钥重用的灾难
print('--- 密钥重用:两段明文 XOR 可消除密钥 ---')
msg1 = b'Secret Attack Plan'
msg2 = b'Defend at Dawn!!'
key2 = os.urandom(18)
c1 = xor_stream_cipher(msg1, key2)
c2 = xor_stream_cipher(msg2, key2)
xor_of_ciphertexts = bytes(a ^ b for a, b in zip(c1, c2))
xor_of_plaintexts = bytes(a ^ b for a, b in zip(msg1, msg2))
print(f' C1 ⊕ C2 = {xor_of_ciphertexts.hex()}')
print(f' P1 ⊕ P2 = {xor_of_plaintexts.hex()}')
print(f' 两者相等: {xor_of_ciphertexts == xor_of_plaintexts}')
print(f' 攻击者只需知道 P1,即可恢复 P2!')
$ python3 xor_stream.py
明文: Hello, Symmetric Encryption!
密钥: 43e3b4972b3ba29a
加密后: 0b86d8fb441782c93a8ed9f25f49cbf963a6daf45942d2ee2a8cdab6
解密后: Hello, Symmetric Encryption!
--- XOR 的数学本质 ---
明文字节 a = 0x48 (H)
密钥字节 k = 0x43
加密 a⊕k = 0x0b
解密 (a⊕k)⊕k = 0x48 (H)
验证: a⊕k⊕k = a
--- 密钥重用:两段明文 XOR 可消除密钥 ---
C1 ⊕ C2 = 170005170b100020005425021c4e714d
P1 ⊕ P2 = 170005170b100020005425021c4e714d
两者相等: True
攻击者只需知道 P1,即可恢复 P2!

XOR 流密码暴露了一个关键问题:密钥流绝不能重复使用。如果两次加密使用了相同的密钥流,攻击者只需将两个密文 XOR,密钥流就被消除了,得到两个明文的 XOR。如果攻击者还知道其中一个明文(比如 HTTP 请求头的固定格式),就能直接恢复另一个明文。这个原理对后面的 AES-GCM 同样适用。

注意:这个简单的 XOR 流密码还有另一个致命缺陷——密钥太短时会循环重复,形成周期性的密钥流。真正的流密码(如 ChaCha20)通过复杂的密钥流生成算法,用短密钥产生极长的不重复密钥流。

附.4 AES-CBC:分组密码与填充#

XOR 流密码虽然数学上优雅,但密钥流管理是个难题。AES 选择了另一条路——分组密码:将明文切成固定大小(128 位)的块,逐块加密。但分组密码引入了新问题:明文长度不一定是 128 位的整数倍,需要填充(Padding);相同的明文块会产生相同的密文块,需要工作模式来引入扩散。CBC(Cipher Block Chaining)模式通过将前一个密文块与当前明文块 XOR 来打破这种模式。

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend
import os
def aes_cbc_encrypt(plaintext, key, iv):
# PKCS7 填充
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
# 加密
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
return ciphertext
def aes_cbc_decrypt(ciphertext, key, iv):
# 解密
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
# 去除 PKCS7 填充
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded) + unpadder.finalize()
return plaintext
key = os.urandom(32) # AES-256
iv = os.urandom(16) # 128-bit IV
plaintext = b'AES-CBC mode requires padding!'
ciphertext = aes_cbc_encrypt(plaintext, key, iv)
decrypted = aes_cbc_decrypt(ciphertext, key, iv)
print(f'明文: {plaintext.decode()}')
print(f'密钥: {key.hex()[:32]}... ({len(key)*8} bit)')
print(f'IV: {iv.hex()}')
print(f'加密后: {ciphertext.hex()}')
print(f'解密后: {decrypted.decode()}')
print()
# 展示填充过程
print('--- PKCS7 填充过程 ---')
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
print(f' 原始长度: {len(plaintext)} 字节')
print(f' 填充后长度: {len(padded)} 字节')
print(f' 填充字节数: {len(padded) - len(plaintext)}')
print(f' 填充内容: {padded[len(plaintext):].hex()} (每字节值为 0x{padded[-1]:02x} = {padded[-1]})')
print()
# 填充不正确时解密失败
print('--- 填充被篡改时解密失败 ---')
tampered = bytearray(ciphertext)
tampered[-1] ^= 0x01 # 篡改最后一个字节
try:
aes_cbc_decrypt(bytes(tampered), key, iv)
print(' 解密成功(不应该发生)')
except Exception as e:
print(f' 解密失败: {type(e).__name__}: {e}')
$ python3 aes_cbc.py
明文: AES-CBC mode requires padding!
密钥: 3187021b59be2bfe23bdcdfd6f6f200d... (256 bit)
IV: a0247230e7dab7e13caf27a8485d2de0
加密后: 572c8eec0294f4a5a5470dbbaf129ebd6625e0a58624c8501e45abb985e7577d
解密后: AES-CBC mode requires padding!
--- PKCS7 填充过程 ---
原始长度: 30 字节
填充后长度: 32 字节
填充字节数: 2
填充内容: 0202 (每字节值为 0x02 = 2)
--- 填充被篡改时解密失败 ---
解密失败: ValueError: Invalid padding bytes.

AES-CBC 解决了 ECB 模式下相同明文块产生相同密文块的问题,但它引入了两个新的攻击面:填充缺乏认证。填充的存在使得 Padding Oracle 攻击成为可能(如本章第五节所述),而缺乏认证意味着密文可以被篡改而不被察觉。这两个问题指向了同一个解决方案——认证加密(AEAD)

注:PKCS7 填充的规则很简单——如果明文最后一块差 n 字节,就填充 n 个值为 n 的字节。如果明文恰好是块大小的整数倍,就添加一个完整的填充块。这样解密时只需检查最后一个字节的值,就知道要移除多少填充字节。

附.5 AES-GCM:认证加密的黄金标准#

AES-CBC 只保证机密性,不保证完整性——密文被篡改后,解密只会得到乱码或错误数据,但接收方无法判断密文是否被篡改。AES-GCM(Galois/Counter Mode)将加密和认证合二为一:CTR 模式负责加密,GHASH 负责认证,两者共享同一个密钥和 nonce。这就是 AEAD(Authenticated Encryption with Associated Data)——同时提供机密性、完整性和真实性。

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce
plaintext = b'AES-GCM: Authenticated Encryption with Associated Data'
aad = b'header: {"alg":"dir","enc":"A256GCM"}'
# 加密
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
# ciphertext 包含密文 + 16 字节认证标签
# 解密
decrypted = aesgcm.decrypt(nonce, ciphertext, aad)
print(f'明文: {plaintext.decode()}')
print(f'密钥: {key.hex()[:32]}... ({len(key)*8} bit)')
print(f'Nonce: {nonce.hex()} ({len(nonce)*8} bit)')
print(f'AAD: {aad.decode()}')
print(f'加密后: {ciphertext.hex()}')
print(f'密文长度: {len(ciphertext)} 字节 (明文 {len(plaintext)} + 16 字节 tag)')
print(f'解密后: {decrypted.decode()}')
print()
# 认证标签验证
print('--- 认证标签验证 ---')
print(f' 密文被篡改时解密会失败:')
tampered = bytearray(ciphertext)
tampered[0] ^= 0x01
try:
aesgcm.decrypt(nonce, bytes(tampered), aad)
print(' 解密成功(不应该发生)')
except Exception as e:
print(f' 解密失败: {type(e).__name__}')
print()
print(f' AAD 被篡改时解密也会失败:')
tampered_aad = b'header: {"alg":"RSA","enc":"A256GCM"}'
try:
aesgcm.decrypt(nonce, ciphertext, tampered_aad)
print(' 解密成功(不应该发生)')
except Exception as e:
print(f' 解密失败: {type(e).__name__}')
$ python3 aes_gcm.py
明文: AES-GCM: Authenticated Encryption with Associated Data
密钥: d9622f288e156538ad6bcbfbcc3e29f4... (256 bit)
Nonce: 095d41c7debd2b0aa5df2e83 (96 bit)
AAD: header: {"alg":"dir","enc":"A256GCM"}
加密后: a8cedd6df4a7368c1339948b945f5380938024f445b5fc41f47130a7b07d5ebce7610ce73d6fad452532813fe49333240eb1003e762a60606ef155a0a93504cd430e871b55d4
密文长度: 70 字节 (明文 54 + 16 字节 tag)
解密后: AES-GCM: Authenticated Encryption with Associated Data
--- 认证标签验证 ---
密文被篡改时解密会失败:
解密失败: InvalidTag
AAD 被篡改时解密也会失败:
解密失败: InvalidTag

AES-GCM 的认证标签(16 字节)是 GHASH 对密文和 AAD 计算的结果。解密时,接收方先重新计算 GHASH,与附带的标签比较——如果不一致,说明密文或 AAD 被篡改,直接拒绝解密。这从根本上消除了 Padding Oracle 攻击的可能,因为攻击者无法让接收方处理篡改后的明文。

注:AAD(Additional Authenticated Data)是不加密但需要认证的数据。在 JWT 加密(JWE)中,Header 就是 AAD——它需要明文传输以便接收方知道用哪个算法解密,但绝不能被篡改。如果攻击者修改了 Header 中的算法字段,AAD 验证会失败,防止了算法混淆攻击。

附.6 nonce 重用:AES-GCM 的致命陷阱#

AES-GCM 几乎完美,但它有一个致命的陷阱:nonce 绝对不能重用。GCM 本质上是 CTR 模式 + GHASH 认证。CTR 模式用 (key, nonce) 生成密钥流,如果两次加密使用了相同的 (key, nonce),就会产生相同的密钥流——回到 XOR 流密码的密钥重用问题。更严重的是,nonce 重用还会破坏 GHASH 的认证安全性,允许攻击者伪造有效的认证标签。

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 同一个 nonce 被重用!
msg1 = b'The secret attack is at dawn'
msg2 = b'Defend the northern gate!'
c1 = aesgcm.encrypt(nonce, msg1, None)
c2 = aesgcm.encrypt(nonce, msg2, None)
# 提取密文部分(去掉最后 16 字节的 tag)
c1_ct = c1[:-16]
c2_ct = c2[:-16]
# XOR 两个密文
xor_ct = bytes(a ^ b for a, b in zip(c1_ct, c2_ct))
# XOR 两个明文
xor_pt = bytes(a ^ b for a, b in zip(msg1, msg2))
print(f'消息 1: {msg1.decode()}')
print(f'消息 2: {msg2.decode()}')
print(f'Nonce: {nonce.hex()} (同一个 nonce!)')
print()
print(f'C1 ⊕ C2 = {xor_ct.hex()}')
print(f'P1 ⊕ P2 = {xor_pt.hex()}')
print(f'两者相等: {xor_ct == xor_pt}')
print()
# 如果攻击者知道 P1,可以恢复 P2
print('--- 攻击者已知 P1,恢复 P2 ---')
recovered_p2 = bytes(a ^ b ^ c for a, b, c in zip(c1_ct, c2_ct, msg1))
# 截断到 msg2 的实际长度
recovered_p2 = recovered_p2[:len(msg2)]
print(f' 恢复的 P2: {recovered_p2.decode()}')
print(f' 原始的 P2: {msg2.decode()}')
print(f' 恢复成功: {recovered_p2 == msg2}')
print()
# nonce 重用还允许伪造认证标签
print('--- nonce 重用还破坏认证安全性 ---')
print(f' 相同 nonce 产生的 GHASH 子密钥 H 相同')
print(f' 攻击者可以构造有效的伪造密文,绕过认证检查')
print(f' 这就是为什么 nonce 绝对不能重用!')
$ python3 nonce_reuse.py
消息 1: The secret attack is at dawn
消息 2: Defend the northern gate!
Nonce: 000e7741b5c97263f9af27e0 (同一个 nonce!)
C1 ⊕ C2 = 100d03451d0143060d11000f1b06150b0e5207534700004545
P1 ⊕ P2 = 100d03451d0143060d11000f1b06150b0e5207534700004545
两者相等: True
--- 攻击者已知 P1,恢复 P2 ---
恢复的 P2: Defend the northern gate!
原始的 P2: Defend the northern gate!
恢复成功: True
--- nonce 重用还破坏认证安全性 ---
相同 nonce 产生的 GHASH 子密钥 H 相同
攻击者可以构造有效的伪造密文,绕过认证检查
这就是为什么 nonce 绝对不能重用!

这个演示清楚地展示了 nonce 重用的灾难性后果:攻击者只需将两个密文 XOR,密钥流就被完全消除。如果攻击者还知道其中一个明文(在实际场景中这很常见——HTTP 请求头、文件头等都有固定格式),就能直接恢复另一个明文。

注意:AES-GCM 的 nonce 重用不仅泄露明文,还会破坏认证安全性。相同的 (key, nonce) 会产生相同的 GHASH 子密钥 H,攻击者可以利用这个信息伪造有效的认证标签,绕过 GCM 的完整性保护。这意味着 nonce 重用不仅让你失去机密性,还让你失去完整性——这是双重灾难。

附.7 实践小结#

从凯撒密码到 AES-GCM,走过了两千年的密码学演进之路。每种方案都在解决前一种方案的缺陷,同时引入新的设计约束:

方案类型密钥空间认证填充主要缺陷安全等级
凯撒密码单表替换26频率分析可破不安全
XOR 流密码流密码2^(key_len)密钥重用可破不安全
AES-CBC分组密码2^256PKCS7Padding Oracle需配合 HMAC
AES-GCMAEAD2^256GHASHnonce 重用灾难推荐

几个关键教训:

  1. 密钥空间大不等于安全——凯撒密码的缺陷不是密钥空间小,而是算法结构本身有漏洞(频率分析)
  2. 加密不等于认证——AES-CBC 只保证机密性,不保证完整性,必须配合 HMAC 或使用 AEAD
  3. nonce/IV 重用是灾难——无论是 XOR 流密码还是 AES-GCM,密钥流重用都会导致密钥被消除
  4. AEAD 是现代标准——AES-GCM 和 ChaCha20-Poly1305 同时提供加密和认证,是当前的最佳实践

八、总结#

上一章从全景视角介绍了密码学全景与威胁模型。

flowchart TD START{"需要对称加密?"} -->|"是"| Q1{"有 AES-NI?"} Q1 -->|"是"| AES_GCM["AES-256-GCM<br/> 推荐"] Q1 -->|"否"| Q2{"需要认证?"} Q2 -->|"是"| CHACHA["ChaCha20-Poly1305<br/> 推荐"] Q2 -->|"否"| Q3{"需要随机访问?"} Q3 -->|"是"| CTR["AES-CTR + HMAC<br/> 需手动组合"] Q3 -->|"否"| CBC["AES-CBC + HMAC<br/> 注意填充安全"] style START fill:#e3f2fd,stroke:#1565c0 style AES_GCM fill:#e8f5e9,stroke:#2e7d32 style CHACHA fill:#e8f5e9,stroke:#2e7d32 style CTR fill:#fff3e0,stroke:#e65100 style CBC fill:#fff3e0,stroke:#e65100
维度关键要点
AES分组密码,256 位密钥,GCM 模式推荐,AES-NI 硬件加速
AES 内部SubBytes + ShiftRows + MixColumns + AddRoundKey,密钥扩展保证雪崩效应
ChaCha20流密码,ARX 结构,移动端性能好,Poly1305 认证,天然常数时间
AEAD认证加密 = 加密 + 认证,Encrypt-then-MAC 最安全,AAD 保护不加密数据
工作模式避免 ECB,推荐 GCM;无 AES-NI 推荐 ChaCha20-Poly1305
填充攻击Padding Oracle 可完全解密 CBC 密文,使用 AEAD 避免填充,统一错误消息
密钥管理安全随机生成,KMS 存储,信封加密,nonce 绝不重用,定期轮换
性能优化有 AES-NI 用 AES-GCM,无 AES-NI 用 ChaCha20-Poly1305,大文件用流式加密

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

对称加密:AES 与 ChaCha20
https://blog.souloss.com/posts/cryptography/symmetric-encryption/
作者
Souloss
发布于
2026-03-09
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时