
本文主要翻译自 Practical-Cryptography-for-Developers-Book,笔者补充了部分代码示例。
《写给开发人员的实用密码学》系列文章目录:
两个常用动词:
另外有几个名词有必要解释:
在密码学里面,最容易搞混的词估计就是「密码」了,cipher/password/passphrase 都可以被翻译成「密码」,需要注意下其中区别。
在密码学中,有两种加密方案被广泛使用:「对称加密」与「非对称加密」。
对称加密是指,使用相同的密钥进行消息的加密与解密。因为这个特性,我们也称这个密钥为「共享密钥(Shared Secret Key)」,示意图如下:

现代密码学中广泛使用的对称加密算法(ciphers)有:AES(AES-128、AES-192、AES-256)、ChaCha20、Twofish、IDEA、Serpent、Camelia、RC6、CAST 等。
其中绝大多数都是「块密码算法(Block Cipher)」或者叫「分组密码算法」,这种算法一次只能加密固定大小的块(例如 128 位);
少部分是「流密码算法(Stream Cipher)」,流密码算法将数据逐字节地加密为密文流。
通过使用称为「分组密码工作模式」的技术,可以将「分组密码算法」转换为「流密码算法」。
即使计算机进入量子时代,仍然可以沿用当前的对称密码算法。因为大多数现代对称密钥密码算法都是抗量子的(quantum-resistant),这意味当使用长度足够的密钥时,强大的量子计算机无法破坏其安全性。
目前来看 256 位的 AES/Twofish 在很长一段时间内都将是 量子安全 的。
我们在第一章「概览」里介绍过,单纯使用数据加密算法只能保证数据的安全性,并不能满足我们对消息真实性、完整性与不可否认性的需求,因此通常我们会将对称加密算法跟其他算法组合成一个「对称加密方案」来使用,这种多个密码学算法组成的「加密方案」能同时保证数据的安全性、真实性、完整性与不可否认性。
一个分组加密方案通常会包含如下几种算法:
而一个流密码加密方案本身就能加密任意长度的数据,因此不需要「分组密码模式」与「消息填充算法」。
如 AES-256-CTR-HMAC-SHA256 就表示一个使用 AES-256 与 Counter 分组模式进行加密,使用 HMAC-SHA256 进行消息认证的加密方案。
其他流行的对称加密方案还有 ChaCha20-Poly1305 和 AES-128-GCM 等,其中 ChaCha20-Poly130 是一个流密码加密方案。我们会在后面单独介绍这两种加密方案。
前面简单介绍了「分组密码工作模式」可以将「分组密码算法」转换为「流密码算法」,从而实现加密任意长度的数据,这里主要就具体介绍下这个分组密码工作模式(下文简称为「分组模式」或者「XXX 模式」)。
加密方案的名称中就带有具体的「分组模式」名称,如:
「分组密码工作模式」背后的主要思想是把明文分成多个长度固定的组,再在这些分组上重复应用分组密码算法进行加密/解密,以实现安全地加密/解密任意长度的数据。
某些分组模式(如 CBC)要求将输入拆分为分组,并使用填充算法(例如添加特殊填充字符)将最末尾的分组填充到块大小。
也有些分组模式(如 CTR、CFB、OFB、CCM、EAX 和 GCM)根本不需要填充,因为它们在每个步骤中,都直接在明文部分和内部密码状态之间执行异或(XOR)运算.
使用「分组模式」加密大量数据的流程基本如下:
解密的流程跟加密完全类似:先初始化算法,然后依次解密所有分组,中间可能会涉及到加密状态的转换。
下面我们来具体介绍下 CTR 与 GCM 两个常见的分组模式。
介绍具体的分组模式前,需要先了解下初始向量 IV(Initialization Vector)这个概念,它有时也被称作 Salt 或者 Nonce。
初始向量 IV 通常是一个随机数,主要作用是往密文中添加随机性,使同样的明文被多次加密也会产生不同的密文,从而确保密文的不可预测性。
IV 的大小应与密码块大小相同,例如 AES、Serpent 和 Camellia 都只支持 128 位密码块,那么它们需要的 IV 也必须也 128 位。
IV 通常无需保密,但是应当足够随机(无法预测),而且不允许重用,应该对每条加密消息使用随机且不可预测的 IV。
一个常见错误是使用相同的对称密钥和相同的 IV 加密多条消息,这使得针对大多数分组模式的各种加密攻击成为可能。
参考文档: https://csrc.nist.gov/publications/detail/sp/800-38a/final
下图说明了「CTR 分组工作模式」的加密解密流程,基本上就是将明文/密文拆分成一个个长度固定的分组,然后使用一定的算法进行加密与解密:


可以看到两图中左边的第一个步骤,涉及到三个参数:
Nonce,初始向量 IV 的别名,前面已经介绍过了。Counter: 一个计数器,最常用的 Counter 实现是「从 0 开始,每次计算都自增 1」Key: 对称加密的密钥Plaintext: 明文的一个分组。除了最后一个分组外,其他分组的长度应该跟 Key 相同CTR 模式加解密的算法使用公式来表示如下:
公式的符号说明如下
上面的公式只描述了 $ 0 \ge i \le n-1$ 的场景,最后一个分组 \(i = n\) 要特殊一些——它的长度可能比 Key 要短。
CTR 模式加解密这最后这个分组时,会直接忽略掉 \(O_n\) 末尾多余的 bytes.
这种处理方式使得 CTR 模式不需要使用填充算法对最后一个分组进行填充,而且还使密文跟明文的长度完全一致。
我们假设最后一个分组的长度为 \(u\),它的加解密算法描述如下(\(MSB_u(O_n)\) 表示取 \(O_n\) 的 u 个最高有效位):
可以看到,因为异或 XOR 的对称性,加密跟解密的算法是完全相同的,直接 XOR \(O_i\) 即可。
Python 中最流行的加密库是 cryptography,requests/flask 底层就使用了它,下面我们使用这个库来演示下 AES-256-CTR 算法:
# pip install cryptography==36.0.1import osfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesplaintext = b"this is a test message, hahahahahaha~"# 使用 32bytes 的 key,即使用算法 AES-256-CTRkey = os.urandom(32)# key => b'\x96\xec.\xc7\xd5\x1b/5\xa1\x10s\x9d\xd5\x10z\xdc\x90\xb5\x1cm">x\xfd \xd5\xc5\xaf\x19\xd1Z\xbb'# AES 算法的 block 大小是固定的 128bits,即 16 bytes, IV 长度需要与 block 一致iv = os.urandom(16)# iv => b'\x88[\xc9\n`\xe4\xc2^\xaf\xdc\x1e\xfd.c>='# 1. 发送方加密数据## 构建 AES-256-CTR 的 cipher,然后加密数据,得到密文cipher = Cipher(algorithms.AES(key), modes.CTR(iv))encryptor = cipher.encryptor()ciphertext = encryptor.update(plaintext) + encryptor.finalize()# ciphertext => b'\x9b6(\x1d\xfd\xde\x96S\x8b\x8f\x90\xc5}ou\x9e\xb1\xbd\x9af\xb8\xdc\xec\xbf\xa3"\x18^\xac\x14\xc8s2*\x1a\xcf\x1d'# 2. 发送方将 iv + ciphertext 发送给接收方# 3. 接收方解密数据# 接收方使用自己的 key + 接收到的 iv,构建 cipher,然后解密出原始数据cipher = Cipher(algorithms.AES(key), modes.CTR(iv))decryptor = cipher.decryptor()decryptor.update(ciphertext) + decryptor.finalize()从上面的算法描述能感觉到,CTR 算法还蛮简单的。下面我使用 Python 写一个能够 work 的 CTR 实现:
def xor_bytes(a, b): """Returns a new byte array with the elements xor'ed. if len(a) != len(b), extra parts are discard. """ return bytes(i^j for i, j in zip(a, b))def inc_bytes(a): """ Returns a new byte array with the value increment by 1 """ out = list(a) for i in reversed(range(len(out))): if out[i] == 0xFF: out[i] = 0 else: out[i] += 1 break return bytes(out)def split_blocks(message, block_size, require_padding=True): """ Split `message` with fixed length `block_size` """ assert len(message) % block_size == 0 or not require_padding return [message[i:i+16] for i in range(0, len(message), block_size)]def encrypt_ctr(block_cipher, plaintext, iv): """ Encrypts `plaintext` using CTR mode with the given nounce/IV. """ assert len(iv) == 16 blocks = [] nonce = iv for plaintext_block in split_blocks(plaintext, block_size=16, require_padding=False): # CTR mode encrypt: plaintext_block XOR encrypt(nonce) o = bytes(block_cipher.encrypt(nonce)) block = xor_bytes(plaintext_block, o) # extra parts of `o` are discard in this step blocks.append(block) nonce = inc_bytes(nonce) return b''.join(blocks)# 加密与解密的算法完全一致decrypt_ctr = encrypt_ctr接下来验证下算法的正确性:
# Python 官方库未提供 AES 实现,因此需要先装下这个库:# pip install pyaes==1.6.1from pyaes import AES# AES-256-CTR - plaintext key 都与前面的测试代码完全一致plaintext = b"this is a test message, hahahahahaha~"key = b'\x96\xec.\xc7\xd5\x1b/5\xa1\x10s\x9d\xd5\x10z\xdc\x90\xb5\x1cm">x\xfd \xd5\xc5\xaf\x19\xd1Z\xbb'# 1. 发送方加密数据# 首先生成一个随机 IV,为了对比,这里使用前面生成好的数据iv = b'\x88[\xc9\n`\xe4\xc2^\xaf\xdc\x1e\xfd.c>='aes_cipher = AES(key)ciphertext = encrypt_ctr(aes_cipher, plaintext, iv)print("ciphertext =>", bytes(ciphertext)) # 输出应该与前面用 cryptography 计算出来的完全一致# ciphertext => b'\x9b6(\x1d\xfd\xde\x96S\x8b\x8f\x90\xc5}ou\x9e\xb1\xbd\x9af\xb8\xdc\xec\xbf\xa3"\x18^\xac\x14\xc8s2*\x1a\xcf\x1d'# 2. 发送方将 ciphertext + iv 发送给接收方# 3. 接收方使用自己的 key 解密数据aes_cipher = AES(key)decrypted_bytes = decrypt_ctr(aes_cipher, ciphertext, iv)print("decrypted_bytes =>", bytes(decrypted_bytes))# decrypted_bytes => b"this is a test message, hahahahahaha~"GCM (Galois/Counter) 模式在 CTR 模式的基础上,添加了消息认证的功能,而且同时还具有与 CTR 模式相同的并行计算能力。因此相比 CTR 模式,GCM 不仅速度一样快,还能额外提供对消息完整性、真实性的验证能力。
下图直观地解释了 GCM 块模式(Galois/Counter 模式)的工作原理:

GCM 模式新增的 Auth Tag,计算起来会有些复杂,我们就直接略过了,对原理感兴趣的可以看下 Galois/Counter_Mode_wiki.
一些 Tips:
nonce 或 salt总之,建议使用 CTR (Counter) 或 GCM (Galois/Counter) 分组模式。
其他的分组在某些情况下可能会有所帮助,但很可能有安全隐患,因此除非你很清楚自己在做什么,否则不要使用其他分组模式!
CTR 和 GCM 加密模式有很多优点:它们是安全的(目前没有已知的重大缺陷),可以加密任意长度的数据而无需填充,可以并行加密和解密分组(在多核 CPU 中)并可以直接解密任意一个密文分组。
因此它们适用于加密加密钱包、文档和流视频(用户可以按时间查找)。
GCM 还提供消息认证,是一般情况下密码块模式的推荐选择。
请注意,GCM、CTR 和其他分组模式会泄漏原始消息的长度,因为它们生成的密文长度与明文消息的长度相同。
如果您想避免泄露原始明文长度,可以在加密前向明文添加一些随机字节(额外的填充数据),并在解密后将其删除。
前面啰嗦了这么多,下面进入正题:对称加密算法
目前应用最广泛的对称加密算法,是 AES 跟 Salsa20 / ChaCha20 这两个系列。
wiki: https://en.wikipedia.org/wiki/Advanced_Encryption_Standard
AES(高级加密标准,也称为 Rijndael)是现代 IT 行业中最流行和广泛使用的对称加密算法。AES 被证明是高度安全、快速且标准化的,到目前为止没有发现任何明显的弱点或攻击手段,而且几乎在所有平台上都得到了很好的支持。 AES 是 128 位分组密码,使用 128、192 或 256 位密钥。它通常与分组模式组合成分组加密方案(如 AES-CTR 或 AES-GCM)以处理流数据。
在大多数分组模式中,AES 还需要一个随机的 128 位初始向量 IV。
Rijndael (AES) 算法可免费用于任何用途,而且非常流行。很多站点都选择 AES 作为 TLS 协议的一部分,以实现安全通信。
现代 CPU 硬件基本都在微处理器级别实现了 AES 指令以加速 AES 加密解密操作。
这里有一个纯 Python 的 AES 实现可供参考: AES encryption in pure Python - boppreh
我们在前面的 CTR 分组模式中已经使用 Python 实践了 AES-256-CTR 加密方案。
而实际上更常用的是支持集成身份验证加密(AEAD)的 AES-256-GCM 加密方案,它的优势我们前面已经介绍过了,这里我们使用 Python 演示下如何使用:
# pip install cryptography==36.0.1import osfrom cryptography.hazmat.primitives.ciphers import ( Cipher, algorithms, modes)def encrypt(key, plaintext, associated_data): # Generate a random 96-bit IV. iv = os.urandom(12) # Construct an AES-GCM Cipher object with the given key and a # randomly generated IV. encryptor = Cipher( algorithms.AES(key), modes.GCM(iv), ).encryptor() # associated_data will be authenticated but not encrypted, # it must also be passed in on decryption. encryptor.authenticate_additional_data(associated_data) # Encrypt the plaintext and get the associated ciphertext. # GCM does not require padding. ciphertext = encryptor.update(plaintext) + encryptor.finalize() return (iv, ciphertext, encryptor.tag)def decrypt(key, associated_data, iv, ciphertext, tag): # Construct a Cipher object, with the key, iv, and additionally the # GCM tag used for authenticating the message. decryptor = Cipher( algorithms.AES(key), modes.GCM(iv, tag), ).decryptor() # We put associated_data back in or the tag will fail to verify # when we finalize the decryptor. decryptor.authenticate_additional_data(associated_data) # Decryption gets us the authenticated plaintext. # If the tag does not match an InvalidTag exception will be raised. return decryptor.update(ciphertext) + decryptor.finalize()# 接下来进行算法验证plaintext = b"this is a paintext, hahahahahaha~"key = b'\x96\xec.\xc7\xd5\x1b/5\xa1\x10s\x9d\xd5\x10z\xdc\x90\xb5\x1cm">x\xfd \xd5\xc5\xaf\x19\xd1Z\xbb'associated_data = b"authenticated but not encrypted payload" # 被用于消息认证的关联数据# 1. 发送方加密消息iv, ciphertext, tag = encrypt( key, plaintext, associated_data)# 2. 发送方将 associated_data iv ciphertext tag 打包发送给接收方# 3. 接收方使用自己的 key 验证并解密数据descrypt_text = decrypt( key, associated_data, iv, ciphertext, tag)wiki: https://en.wikipedia.org/wiki/Salsa20#ChaCha_variant
Salsa20 及其改进的变体 ChaCha(ChaCha8、ChaCha12、ChaCha20)和 XSalsa20 是由密码学家 Daniel Bernstein 设计的现代、快速的对称流密码家族。 Salsa20 密码是对称流密码设计竞赛 eSTREAM(2004-2008)的决赛选手之一,它随后与相关的 BLAKE 哈希函数一起被广泛采用。 Salsa20 及其变体是免版税的,没有专利。
Salsa20 密码将 128 位或 256 位对称密钥 + 随机生成的 64 位随机数(初始向量)和无限长度的数据流作为输入,并生成长度相同的加密数据流作为输出输入流。
Salsa20 应用最为广泛的是认证加密方案:ChaCha20-Poly1305,即组合使用 ChaCha20 与消息认证算法 Poly1305,它们都由密码学家 Bernstein 设计。
ChaCha20-Poly1305 已被证明足够安全,不过跟 GCM 一样它的安全性也依赖于足够随机的初始向量 IV,另外 ChaCha20-Poly1305 也不容易遭受计时攻击。
在没有硬件加速的情况下,ChaCha20 通常比 AES 要快得多(比如在旧的没有硬件加速的移动设备上),这是它最大的优势。
以下是一个 ChaCha20 的 Python 示例:
# pip install cryptography==36.0.1import osfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesplaintext = b"this is a paintext, hahahahahaha~"key = b'\x96\xec.\xc7\xd5\x1b/5\xa1\x10s\x9d\xd5\x10z\xdc\x90\xb5\x1cm">x\xfd \xd5\xc5\xaf\x19\xd1Z\xbb'nonce = os.urandom(16)algorithm = algorithms.ChaCha20(key, nonce)# ChaCha20 是一个流密码,mode 必须为 Nonecipher = Cipher(algorithm, mode=None)# 1. 加密encryptor = cipher.encryptor()ct = encryptor.update(plaintext)# 2. 解密decryptor = cipher.decryptor()decryptor.update(ct)还有一些其他的现代安全对称密码,它们的应用不如 AES 和 ChaCha20 这么广泛,但在程序员和信息安全社区中仍然很流行:
具体的算法内容这里就不介绍了,有兴趣或者用得到的时候,可以再去仔细了解。
如下这些对称加密算法曾经很流行,但现在被认为是不安全的或有争议的安全性,不建议再使用:
我们在前面第三篇文章「MAC 与密钥派生函数 KDF」中介绍过 AE 认证加密及其变体 AEAD.
一些对称加密方案提供集成身份验证加密(AEAD),比如使用了 GCM 分组模式的加密方案 AES-GCM,而其他加密方案(如 AES-CBC 和 AES-CTR)自身不提供身份验证能力,需要额外添加。
最流行的认证加密(AEAD)方案有如下几个,我们在之前已经简单介绍过它们:
今天的大多数应用程序应该优先选用上面这些加密方案进行对称加密,而不是自己造轮子。
上述方案是高度安全的、经过验证的、经过良好测试的,并且大多数加密库都已经提供了高效的实现,可以说是开箱即用。
目前应用最广泛的对称加密方案应该是 AES-128-GCM,
而 ChaCha20-Poly1305 因为其极高的性能,也越来越多地被应用在 TLS1.2、TLS1.3、QUIC/HTTP3、Wireguard、SSH 等协议中。
在这一小节我们研究一个现实中的 AES 应用场景:以太坊区块链的标准加密钱包文件格式。
我们将看到 AES-128-CTR 密码方案如何与 Scrypt 和 MAC 相结合,通过字符密码安全地实现经过身份验证的对称密钥加密。
在比特币和以太坊等区块链网络中,区块链资产持有者的私钥存储在称为加密钱包的特殊密钥库中。
通常,这些加密钱包是本地硬盘上的文件,并使用字符密码加密。
在以太坊区块链中,加密钱包以一种特殊的加密格式在内部存储,称为「UTC / JSON 钱包(密钥库文件)」或「Web3 秘密存储定义」。
这是一种加密钱包的文件格式,被广泛应用在 geth 和 Parity(以太坊的主要协议实现)、MyEtherWallet(流行的在线客户端以太坊钱包)、MetaMask(广泛使用的浏览器内以太坊钱包)、ethers.js 和 Nethereum 库以及许多其他与以太坊相关的技术和工具中。
以太坊 UTC/JSON 密钥库将加密的私钥、加密数据、加密算法及其参数保存为 JSON 文本文档。
UTC / JSON 钱包的一个示例如下:
{ "version": 3, "id": "07a9f767-93c5-4842-9afd-b3b083659f04", "address": "aef8cad64d29fcc4ed07629b9e896ebc3160a8d0", "Crypto": { "ciphertext": "99d0e66c67941a08690e48222a58843ef2481e110969325db7ff5284cd3d3093", "cipherparams": { "iv": "7d7fabf8dee2e77f0d7e3ff3b965fc23" }, "cipher": "aes-128-ctr", "kdf": "scrypt", "kdfparams": { "dklen": 32, "salt": "85ad073989d461c72358ccaea3551f7ecb8e672503cb05c2ee80cfb6b922f4d4", "n": 8192, "r": 8, "p": 1 }, "mac": "06dcf1cc4bffe1616fafe94a2a7087fd79df444756bb17c93af588c3ab02a913" }}上述 json 内容也是认证对称加密的一个典型示例,可以很容易分析出它的一些组成成分:
kdf: 用于从字符密码派生出密钥的 KDF 算法名称,这里用的是 scryptkdfparams: KDF 算法的参数,如迭代参数、盐等...ciphertext: 钱包内容的密文,通常这就是一个被加密的 256 位私钥cipher + cipherparams: 对称加密算法的名称及参数,这里使用了 AES-128-CTR,并给出了初始向量 IVmac: 由 MAC 算法生成的消息认证码,被用于验证解密密码的正确性默认情况下,密钥派生函数是 scrypt 并使用的是弱 scrypt 参数(n=8192 成本因子,r=8 块大小,p=1 并行化),因此建议使用长而复杂的密码以避免钱包被暴力解密。