加密保证的是机密性(Confidentiality):攻击者无法读懂密文的内容。但加密本身并不保证完整性(Integrity)——一段密文在传输过程中可能被恶意篡改,而接收方在解密后得到的是一条被修改过的、毫无意义甚至具有恶意的消息,却浑然不觉。更危险的是,对于许多加密模式(例如 CTR 模式),攻击者甚至不需要知道密钥就能对密文进行精确的、可预测的修改——因为 CTR 模式下密文与明文是异或关系,翻转密文的某一位就精确地翻转了对应明文的同一位。
消息认证码(Message Authentication Code,MAC)正是为了解决这一问题而设计的密码学原语。MAC 提供了一种机制,使得接收方能够验证消息确实来自持有共享密钥的发送方,并且在传输过程中没有被任何人篡改。MAC 是对称密码学中与加密同等重要的基础构件,现代几乎所有安全通信协议——TLS、IPsec、SSH、Signal——都同时依赖加密和 MAC(或将两者融合为认证加密 AEAD)来保证通信的安全。
本文将从 MAC 的形式化定义与安全模型出发,深入分析为什么看似自然的”Hash(key||message)“构造是不安全的,然后详细剖析 HMAC 的设计原理与安全性证明,进而介绍基于分组密码的 CBC-MAC/CMAC、基于 Galois 域的 GMAC、以及优雅的 Poly1305 等方案,最后讨论 MAC 在工程实践中的常见误用与特殊用途方案。
一、消息认证码的形式化定义
一个消息认证码方案由三个算法组成,通常记作 Π = (Gen, Tag, Verify):
密钥生成算法 Gen:Gen(1ⁿ) → K。输入安全参数 n,输出一个密钥 K。在大多数实际 MAC 方案中,Gen 简单地从 {0, 1}ⁿ 中均匀随机选取一个 n 比特的密钥。
标签生成算法 Tag:Tag(K, M) → t。输入密钥 K 和消息 M,输出一个认证标签(Tag)t。标签的长度通常是固定的(例如 128 比特或 256 比特),与消息长度无关。Tag 算法可以是确定性的(Deterministic),也可以是随机化的(Randomized)——确定性 MAC 对同一消息始终产生相同的标签,随机化 MAC 则可能每次产生不同的标签。在实践中,绝大多数 MAC 方案是确定性的,HMAC 就是典型的确定性 MAC。
验证算法 Verify:Verify(K, M, t) → {accept, reject}。输入密钥 K、消息 M 和标签 t,输出接受或拒绝。对于确定性 MAC,验证通常只需重新计算 Tag(K, M) 并与 t 比较。对于随机化 MAC,验证算法可能需要更复杂的逻辑。
正确性要求是自然的:对于任何由 Gen 生成的密钥 K 和任何消息 M,Verify(K, M, Tag(K, M)) 总是输出 accept。
EUF-CMA 安全性
MAC 方案的标准安全定义是选择消息攻击下的存在性不可伪造性(Existential Unforgeability under Chosen-Message Attack,EUF-CMA)。这是通过一个攻击者与挑战者之间的安全游戏来定义的:
- 挑战者运行 Gen 生成密钥 K,对攻击者保密。
- 攻击者可以自适应地(Adaptively)选择任意消息 M₁, M₂, …, Mq,并获得对应的合法标签 t₁ = Tag(K, M₁), t₂ = Tag(K, M₂), …, tq = Tag(K, Mq)。这模拟了攻击者可以观察到合法通信方产生的消息-标签对的场景。
- 攻击者输出一个伪造尝试 (M, t)。
- 如果 Verify(K, M, t) = accept,并且 M* 不等于之前查询过的任何 Mᵢ,则攻击者获胜。
一个 MAC 方案是 EUF-CMA 安全的,当且仅当任何多项式时间(Probabilistic Polynomial Time,PPT)攻击者在上述游戏中获胜的概率可以忽略不计(Negligible)。
注意 EUF-CMA 的两个关键要素。第一,“选择消息攻击”意味着攻击者可以获得任意消息的合法标签——这是一个非常强的攻击模型,但在实际场景中是合理的:攻击者可能通过诱导合法用户发送特定消息来获取对应的标签。第二,“存在性不可伪造”意味着攻击者只需要对任何一条新消息伪造出合法标签就算获胜——即使这条消息是无意义的。这确保了 MAC 对任何可能的伪造都具有抵抗力。
还有一种更强的安全定义是强不可伪造性(Strong Unforgeability,SUF-CMA):即使对于之前查询过的消息 Mᵢ,攻击者也不能产生一个不同于 tᵢ 的新合法标签。换言之,攻击者不能对已知消息产生第二个合法标签。对于确定性 MAC,SUF-CMA 与 EUF-CMA 等价(因为确定性 MAC 对同一消息只有一个合法标签),但对于随机化 MAC,SUF-CMA 是更强的要求。在某些应用场景(如认证加密)中,SUF-CMA 是必需的。
关于标签长度的说明
MAC 标签的长度直接影响安全性的上界。对于一个标签长度为 τ 比特的 MAC,攻击者总是可以通过随机猜测来以 2⁻τ 的概率成功伪造。因此,τ 必须足够大以使这个概率可以忽略不计。在实践中,128 比特的标签长度被认为足够安全,256 比特则提供了更大的安全余量。某些应用允许截断标签(例如只使用 HMAC-SHA256 输出的前 128 比特),但截断后的安全性相应降低——伪造的成功概率上界变为 2⁻¹²⁸。
二、为什么 Hash(key||message) 不安全
在设计 MAC 时,一个看似自然而合理的想法是:既然密码学哈希函数 H 具有碰撞抵抗性(Collision Resistance)和不可逆性,为什么不直接定义 MAC(K, M) = H(K || M) 呢?密钥 K 是攻击者不知道的秘密,将它前置于消息之前再哈希,似乎应该产生一个攻击者无法伪造的标签。
这个直觉是错误的。对于所有基于 Merkle-Damgård 构造的哈希函数——包括 MD5、SHA-1 和 SHA-256——这种构造方式会遭受长度扩展攻击(Length Extension Attack),攻击者可以在不知道密钥的情况下伪造新消息的合法标签。
Merkle-Damgård 构造的回顾
要理解长度扩展攻击,首先需要回顾 Merkle-Damgård 构造的工作方式。Merkle-Damgård 哈希函数将输入消息分成固定大小的块(例如 SHA-256 中每块 512 比特),然后通过压缩函数 f 逐块处理:
h₀ = IV (初始向量,公开常量)
h₁ = f(h₀, m₁) (处理第一个消息块)
h₂ = f(h₁, m₂) (处理第二个消息块)
...
hₙ = f(hₙ₋₁, mₙ) (处理最后一个消息块)
H(M) = hₙ (最终哈希值等于最后的链式变量)
这里的关键观察是:哈希值 H(M) 就等于最后一个链式变量(Chaining Variable)hₙ。这意味着知道 H(M) 就等于知道了处理完整个消息 M 之后的内部状态。
长度扩展攻击详解
假设攻击者观察到了 t = H(K || M),其中 K 是未知密钥,M 是已知消息。攻击者虽然不知道 K,但他知道:
- 哈希值 t 就是处理完 K || M || padding 之后的内部状态。这里 padding 是 Merkle-Damgård 构造自动附加的填充位(包含消息长度编码)。
- 压缩函数 f 是公开的。
因此,攻击者可以选择任意的后缀消息 M’,然后从 t 作为初始状态继续运行压缩函数:
t' = f(t, M'₁)
t'' = f(t', M'₂)
...
t_final = finalize(...)
最终得到的 t_final 就等于 H(K || M || padding || M’)。
这意味着攻击者成功地计算出了消息 M* = M || padding || M’ 的合法 MAC 标签,而从未知晓密钥 K!只要 M* 不同于原始消息 M(在 M’ 非空时显然成立),这就是一次成功的 EUF-CMA 伪造。
攻击者需要知道 K 的长度(以正确构造填充),但 K 的长度通常是已知的或可以穷举(常见密钥长度只有几种)。即使不知道确切长度,攻击者也可以对每种可能的密钥长度分别尝试伪造。
攻击演示
以下 Python 代码演示了对 SHA-256 的长度扩展攻击。我们使用纯 Python 实现的 SHA-256 内部状态恢复来展示攻击原理:
import struct
import hashlib
# SHA-256 内部常量与函数
K_SHA256 = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]
def rr(x, n):
"""右旋转 32 位整数"""
return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF
def sha256_compress(state, block):
"""SHA-256 压缩函数:处理单个 512 位块"""
assert len(block) == 64
w = list(struct.unpack('>16L', block))
for i in range(16, 64):
s0 = rr(w[i-15], 7) ^ rr(w[i-15], 18) ^ (w[i-15] >> 3)
s1 = rr(w[i-2], 17) ^ rr(w[i-2], 19) ^ (w[i-2] >> 10)
w.append((w[i-16] + s0 + w[i-7] + s1) & 0xFFFFFFFF)
a, b, c, d, e, f, g, h = state
for i in range(64):
S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25)
ch = (e & f) ^ (~e & g) & 0xFFFFFFFF
temp1 = (h + S1 + ch + K_SHA256[i] + w[i]) & 0xFFFFFFFF
S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22)
maj = (a & b) ^ (a & c) ^ (b & c)
temp2 = (S0 + maj) & 0xFFFFFFFF
h, g, f, e, d, c, b, a = (
g, f, e, (d + temp1) & 0xFFFFFFFF,
c, b, a, (temp1 + temp2) & 0xFFFFFFFF
)
return tuple((s + x) & 0xFFFFFFFF for s, x in
zip(state, (a, b, c, d, e, f, g, h)))
def md_padding(msg_len_bytes):
"""生成 Merkle-Damgård 填充(SHA-256 规范)"""
bit_len = msg_len_bytes * 8
# 填充:0x80 + 零字节 + 8 字节长度(大端序)
padding = b'\x80'
# 需要填充到 (msg_len_bytes + len(padding)) % 64 == 56
pad_zero = (55 - msg_len_bytes) % 64
padding += b'\x00' * pad_zero
padding += struct.pack('>Q', bit_len)
return padding
def length_extension_attack(known_hash_hex, known_msg, key_len, append_data):
"""
长度扩展攻击:
已知 H(key || known_msg) 和 key 的长度,
伪造 H(key || known_msg || padding || append_data)
"""
# 步骤 1:从已知哈希恢复内部状态
known_hash = bytes.fromhex(known_hash_hex)
state = struct.unpack('>8L', known_hash)
# 步骤 2:计算 key || known_msg 的填充
original_len = key_len + len(known_msg)
glue_padding = md_padding(original_len)
# 步骤 3:从恢复的状态继续处理 append_data
# 已处理的字节总数 = original_len + len(glue_padding)
total_processed = original_len + len(glue_padding)
# 对 append_data 进行填充
forged_msg_tail = append_data + md_padding(total_processed + len(append_data))
# 逐块处理
for i in range(0, len(forged_msg_tail), 64):
block = forged_msg_tail[i:i+64]
state = sha256_compress(state, block)
forged_hash = struct.pack('>8L', *state)
# 构造伪造的完整消息(不含密钥部分)
forged_msg = known_msg + glue_padding + append_data
return forged_hash.hex(), forged_msg
# ===== 演示 =====
if __name__ == '__main__':
secret_key = b'SuperSecretKey!!' # 16 字节密钥(攻击者不知道)
original_msg = b'amount=100&to=alice'
# 合法标签:服务器用 H(key || msg) 计算
legitimate_tag = hashlib.sha256(secret_key + original_msg).hexdigest()
print(f"原始消息: {original_msg}")
print(f"合法标签: {legitimate_tag}")
# 攻击者只知道 legitimate_tag、original_msg 和 key 的长度
append_data = b'&to=mallory'
forged_hash, forged_msg = length_extension_attack(
legitimate_tag, original_msg,
key_len=len(secret_key), append_data=append_data
)
# 验证:用完整密钥直接计算
real_hash = hashlib.sha256(secret_key + forged_msg).hexdigest()
print(f"\n伪造消息: {forged_msg}")
print(f"伪造标签: {forged_hash}")
print(f"真实标签: {real_hash}")
print(f"攻击成功: {forged_hash == real_hash}")运行这段代码,你会看到
攻击成功: True。攻击者在完全不知道密钥的情况下,成功地将
&to=mallory
附加到原始消息之后,并伪造出了正确的认证标签。如果服务器使用
H(key || message) 来验证请求,攻击者就能将资金转账的目的地从
alice 篡改为 mallory。
为什么后置密钥也不安全
有人可能会想:既然前置密钥容易遭受长度扩展攻击,那 H(message || key) 是否安全?这种构造确实避免了长度扩展攻击,但它存在另一个问题:如果攻击者能找到 H 的碰撞——即找到 M ≠ M’ 使得 H(M) = H(M’)——那么 H(M || K) = H(M’ || K),因此两条不同消息有相同的 MAC。这意味着 H(message || key) 的安全性完全依赖于哈希函数的碰撞抵抗性。而碰撞抵抗性是一个比 PRF(伪随机函数)安全性更强的假设——碰撞抗性可能被打破而 PRF 安全性仍然保持。
因此,无论是 H(key || message) 还是 H(message || key),直接将密钥和消息简单拼接后哈希都不是构造安全 MAC 的正确方法。我们需要更精心设计的构造——这就是 HMAC。
三、HMAC 构造
HMAC(Hash-based Message Authentication Code)由 Bellare、Canetti 和 Krawczyk 在 1996 年提出,是目前使用最广泛的 MAC 方案之一。HMAC 的设计目标是:利用任何已有的密码学哈希函数 H 来构造安全的 MAC,同时保持简单高效,并提供可证明的安全性。
构造细节
HMAC 的定义如下:
HMAC(K, M) = H((K' ⊕ opad) || H((K' ⊕ ipad) || M))
其中:
- K 是密钥。如果 K 的长度超过哈希函数的块大小 B(SHA-256 中 B = 64 字节),则先将 K 替换为 H(K)。然后将 K 右填充零字节至 B 字节长,得到 K’。
- ipad(inner pad)是字节 0x36 重复 B 次。
- opad(outer pad)是字节 0x5C 重复 B 次。
- ⊕ 表示按字节异或。
展开来看,HMAC 的计算过程分为两步:
内层哈希:计算 inner = H((K’ ⊕ ipad) || M)。首先将密钥 K’ 与 ipad 异或,得到一个 B 字节的值;然后将这个值与消息 M 拼接起来;最后计算这个拼接结果的哈希值。
外层哈希:计算 HMAC = H((K’ ⊕ opad) || inner)。将密钥 K’ 与 opad 异或,得到另一个 B 字节的值;然后将这个值与内层哈希的结果拼接;最后再计算一次哈希。
这种”双层嵌套”的设计是 HMAC 安全性的关键。
为什么嵌套哈希能解决长度扩展问题
长度扩展攻击的前提是:攻击者知道哈希计算的最终内部状态,并能从该状态继续计算。在 H(K || M) 中,最终输出就是内部状态,攻击者可以直接利用。
HMAC 的外层哈希打破了这个前提。假设攻击者知道 HMAC(K, M) 的值。这个值是 H((K’ ⊕ opad) || inner) 的输出。如果攻击者想对外层哈希进行长度扩展,他需要从这个输出继续计算 H((K’ ⊕ opad) || inner || extension)。但这要求他能够构造一个以 (K’ ⊕ opad) 开头的消息——而 K’ ⊕ opad 包含未知的密钥 K,攻击者无法做到这一点。
同样,如果攻击者试图对内层哈希进行长度扩展:他可以计算 H((K’ ⊕ ipad) || M || extension),但这只是内层哈希的结果。要得到有效的 HMAC,他还需要用 K’ ⊕ opad 进行外层哈希计算——这同样需要知道密钥。
两层嵌套相互保护,从根本上消除了长度扩展攻击的可能性。
Bellare-Canetti-Krawczyk 安全性证明
Bellare、Canetti 和 Krawczyk 在他们 1996 年的论文(及后续的 2006 年更新)中证明了 HMAC 的安全性。证明的核心思路可以概括为以下归约链:
定理(非形式化表述):如果底层哈希函数 H 的压缩函数 f 是一个伪随机函数(PRF),那么 HMAC-H 是一个伪随机函数。
证明分为两步:
第一步:从压缩函数 PRF 到嵌套 MAC 的 PRF。首先证明 NMAC(Nested MAC)的安全性。NMAC 使用两个独立的密钥 K₁ 和 K₂:
NMAC(K₁, K₂, M) = f_K₁(H_K₂(M))
其中 H_K₂ 表示用 K₂ 代替 IV 初始化的哈希迭代,f_K₁ 表示以 K₁ 为密钥的压缩函数。如果压缩函数 f 是 PRF,那么: - 内层 H_K₂ 是 PRF(因为用随机密钥初始化 Merkle-Damgård 迭代保持 PRF 性质)。 - 外层 f_K₁ 对内层输出再做一次 PRF 变换,结果仍为 PRF。
第二步:从 NMAC 到 HMAC。HMAC 本质上是 NMAC 的一个特例,其中两个密钥 K₁ = f(IV, K’ ⊕ opad) 和 K₂ = f(IV, K’ ⊕ ipad) 都是从同一个密钥 K 通过压缩函数导出的。只要 K’ ⊕ opad ≠ K’ ⊕ ipad(因为 opad ≠ ipad,这显然成立),两个导出密钥就在密码学意义上独立——前提是压缩函数 f 是 PRF。
这个安全性证明的一个重要优点是:它只要求压缩函数是 PRF,而不要求完整的哈希函数具有碰撞抵抗性。这意味着即使 SHA-256 的碰撞抵抗性被打破(理论上,如果有人找到了碰撞),HMAC-SHA256 仍然可能是安全的,只要压缩函数的 PRF 性质不被破坏。事实上,MD5 的碰撞抵抗性早已被打破,但 HMAC-MD5 至今仍没有已知的实际攻击——这正是因为 HMAC 的安全性不依赖于碰撞抵抗性。
C 语言实现
以下是 HMAC-SHA256 的 C 语言实现,使用 OpenSSL 的 SHA-256 作为底层哈希函数:
#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>
#define BLOCK_SIZE 64 /* SHA-256 块大小(字节) */
#define HASH_SIZE 32 /* SHA-256 输出大小(字节) */
/*
* hmac_sha256 -- 计算 HMAC-SHA256
*
* key: 密钥
* key_len: 密钥长度(字节)
* msg: 消息
* msg_len: 消息长度(字节)
* out: 输出缓冲区,至少 HASH_SIZE 字节
*/
void hmac_sha256(const unsigned char *key, size_t key_len,
const unsigned char *msg, size_t msg_len,
unsigned char *out)
{
unsigned char k_prime[BLOCK_SIZE];
unsigned char i_key_pad[BLOCK_SIZE];
unsigned char o_key_pad[BLOCK_SIZE];
unsigned char inner_hash[HASH_SIZE];
SHA256_CTX ctx;
size_t i;
/* 步骤 1:如果密钥长度 > 块大小,先哈希密钥 */
memset(k_prime, 0, BLOCK_SIZE);
if (key_len > BLOCK_SIZE) {
SHA256(key, key_len, k_prime); /* K' = H(K), 后续字节已清零 */
} else {
memcpy(k_prime, key, key_len); /* K' = K || 0x00..., 后续字节已清零 */
}
/* 步骤 2:计算 K' ⊕ ipad 和 K' ⊕ opad */
for (i = 0; i < BLOCK_SIZE; i++) {
i_key_pad[i] = k_prime[i] ^ 0x36;
o_key_pad[i] = k_prime[i] ^ 0x5C;
}
/* 步骤 3:内层哈希 inner = H((K' ⊕ ipad) || M) */
SHA256_Init(&ctx);
SHA256_Update(&ctx, i_key_pad, BLOCK_SIZE);
SHA256_Update(&ctx, msg, msg_len);
SHA256_Final(inner_hash, &ctx);
/* 步骤 4:外层哈希 HMAC = H((K' ⊕ opad) || inner) */
SHA256_Init(&ctx);
SHA256_Update(&ctx, o_key_pad, BLOCK_SIZE);
SHA256_Update(&ctx, inner_hash, HASH_SIZE);
SHA256_Final(out, &ctx);
/* 清理敏感数据 */
memset(k_prime, 0, sizeof(k_prime));
memset(i_key_pad, 0, sizeof(i_key_pad));
memset(o_key_pad, 0, sizeof(o_key_pad));
memset(inner_hash, 0, sizeof(inner_hash));
}
/* 辅助函数:打印十六进制 */
static void print_hex(const char *label, const unsigned char *data, size_t len)
{
size_t i;
printf("%s", label);
for (i = 0; i < len; i++)
printf("%02x", data[i]);
printf("\n");
}
int main(void)
{
/* RFC 4231 测试向量 #2 */
const unsigned char key[] = "Jefe";
const unsigned char msg[] = "what do ya want for nothing?";
unsigned char mac[HASH_SIZE];
hmac_sha256(key, 4, msg, 28, mac);
print_hex("HMAC-SHA256: ", mac, HASH_SIZE);
/* 期望输出:
* 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
*/
/* 使用较长密钥的示例 */
const unsigned char long_key[] =
"This is a key that is longer than the block size of SHA-256"
" which is 64 bytes so it will be hashed first";
const unsigned char msg2[] = "Hello, HMAC!";
unsigned char mac2[HASH_SIZE];
hmac_sha256(long_key, strlen((const char *)long_key),
msg2, strlen((const char *)msg2), mac2);
print_hex("HMAC (long key): ", mac2, HASH_SIZE);
return 0;
}这个实现清晰地展示了 HMAC 的四个步骤:密钥预处理、内外填充异或、内层哈希和外层哈希。注意在函数结束时清零所有中间敏感数据——这是密码学代码的基本卫生习惯。
四、HMAC 作为 PRF
HMAC 的安全性证明实际上证明了一个比 MAC 安全性更强的性质:HMAC 是一个伪随机函数(Pseudorandom Function,PRF)。PRF 意味着对于随机选取的密钥 K,HMAC(K, ·) 的输入-输出行为与一个真正的随机函数在计算上不可区分。PRF 比 MAC 更强,因为任何安全的 PRF 自然也是安全的 MAC(一个能区分 PRF 与随机函数的攻击者可以用来伪造 MAC,反过来则未必成立)。
HMAC 的 PRF 性质使它不仅仅是一个 MAC——它还可以用作通用的密码学密钥导出工具,这正是 HKDF 和 TLS PRF 的理论基础。
HKDF:基于 HMAC 的密钥导出
HKDF(HMAC-based Key Derivation Function)由 Krawczyk 在 RFC 5869 中标准化,是目前推荐使用的密钥导出函数。HKDF 分为两个阶段:
Extract 阶段:PRK = HMAC(salt, IKM)。将输入密钥材料 IKM(Input Keying Material)通过 HMAC 提炼为一个固定长度的伪随机密钥 PRK。salt 是一个可选的随机值,用于增加提取的密钥的随机性。这一步的目的是”浓缩”可能分布不均匀的输入密钥材料,使其具有充分的伪随机性。
Expand 阶段:从 PRK 导出任意长度的输出密钥材料 OKM。通过反复计算 HMAC(PRK, T(i-1) || info || counter) 来生成所需长度的输出,其中 info 是上下文相关的标签信息。
HKDF 的安全性直接依赖于 HMAC 的 PRF 性质:Extract 阶段保证了即使输入密钥材料是低熵或非均匀的(例如 Diffie-Hellman 共享密钥,其分布不是均匀随机的),输出 PRK 也在计算上不可区分于随机;Expand 阶段保证了从同一个 PRK 导出的多个密钥在计算上相互独立。
TLS 中的 PRF
在 TLS 1.2 及之前的版本中,伪随机函数 PRF 是协议的核心组件,用于从预主密钥(Pre-Master Secret)导出主密钥、从主密钥导出会话密钥,以及生成 Finished 消息的验证数据。TLS 1.2 的 PRF 基于 HMAC,使用 P_hash 构造:
P_hash(secret, seed) = HMAC(secret, A(1) || seed) ||
HMAC(secret, A(2) || seed) || ...
其中 A(0) = seed, A(i) = HMAC(secret, A(i-1))
TLS 1.3 则完全采用 HKDF 作为密钥导出机制,进一步简化和规范化了密钥导出的流程。整个 TLS 1.3 握手中的每一个密钥都通过一个精心设计的 HKDF 密钥调度(Key Schedule)导出,每一步都使用不同的标签和上下文信息来确保密钥分离。
HMAC 在其他协议中的 PRF 应用
HMAC 的 PRF 性质还被广泛应用于其他场景。TOTP(Time-based One-Time Password,基于时间的一次性密码)协议——也就是 Google Authenticator 等两步验证应用所使用的协议——使用 HMAC-SHA1 将共享密钥与当前时间戳组合生成一次性密码。JWT(JSON Web Token)使用 HMAC-SHA256 来签名令牌。PBKDF2(Password-Based Key Derivation Function 2)使用 HMAC 作为内部 PRF,通过多次迭代将密码转化为密钥。
五、CBC-MAC 与 CMAC
与 HMAC 基于哈希函数不同,CBC-MAC 和 CMAC 是基于分组密码(Block Cipher)的 MAC 方案。在只有分组密码可用而没有哈希函数的环境中(例如某些嵌入式系统或硬件安全模块 HSM),基于分组密码的 MAC 是自然的选择。
CBC-MAC 基本构造
CBC-MAC 的构造类似于 CBC 加密模式,但不输出中间密文,只输出最后一个块的加密结果作为标签:
t₀ = 0 (初始向量为全零)
t₁ = E_K(M₁ ⊕ t₀) = E_K(M₁)
t₂ = E_K(M₂ ⊕ t₁)
...
tₙ = E_K(Mₙ ⊕ tₙ₋₁)
MAC = tₙ
其中 E_K 是以 K 为密钥的分组密码加密函数,M₁, M₂, …, Mₙ 是消息分成的块。
对于固定长度的消息,CBC-MAC 是可证明安全的:如果 E 是一个伪随机置换(PRP),那么 CBC-MAC 对于所有长度恰好为 l 块的消息是一个安全的 PRF。
为什么原始 CBC-MAC 对变长消息不安全
然而,原始 CBC-MAC 对于可变长度的消息是不安全的。攻击非常简单:
假设攻击者知道两个合法的消息-标签对: - MAC(K, M₁) = t₁(M₁ 为单块消息) - MAC(K, M₂) = t₂(M₂ 为单块消息)
那么攻击者可以构造消息 M* = M₁ || (M₂ ⊕ t₁)(两块消息),其 MAC 为:
第一块处理:E_K(M₁) = t₁
第二块处理:E_K((M₂ ⊕ t₁) ⊕ t₁) = E_K(M₂) = t₂
因此 MAC(K, M*) = t₂,攻击者成功伪造!这个攻击利用了 CBC 链接中异或操作的可消去性。
EMAC:加密最终块
修复 CBC-MAC 变长消息问题的一种方法是 EMAC(Encrypted MAC):使用第二个独立密钥 K₂ 对 CBC-MAC 的输出再加密一次:
EMAC(K₁, K₂, M) = E_K₂(CBC-MAC_K₁(M))
EMAC 对于可变长度消息是可证明安全的。但它需要两个独立密钥,且仍然要求消息长度是块大小的整数倍(需要额外的填充方案)。
CMAC:NIST 标准化方案
CMAC(Cipher-based MAC)由 NIST 在 SP 800-38B 中标准化,是对 CBC-MAC 的优雅修复。CMAC 仅使用一个密钥 K,但从 K 导出两个子密钥 K₁ 和 K₂:
子密钥生成: 1. 计算 L = E_K(0)(用全零块加密)。 2. K₁ = L << 1(左移一位),如果 L 的最高位为 1,则额外异或常数 Rb(对于 128 位块,Rb = 0x87)。 3. K₂ = K₁ << 1,同样根据最高位决定是否异或 Rb。
标签计算:处理消息的前 n-1 块与标准 CBC-MAC 相同。对最后一块的处理取决于消息长度是否是块大小的整数倍: - 如果消息长度是块大小的整数倍:最后一块异或 K₁ 后再加密。 - 如果不是:对最后一块进行 10*(即附加一个 1 比特和若干 0 比特)填充至块大小,然后异或 K₂ 后再加密。
使用不同的子密钥处理最后一块,巧妙地区分了”消息恰好填满最后一块”和”消息需要填充”两种情况,避免了歧义,同时保持了对可变长度消息的安全性。
CMAC 基于 AES-128 时,标签长度为 128 比特,安全性约为 64 比特(生日攻击界限为 2⁶⁴ 次查询)。这对于许多应用场景是足够的,但对于需要超过 2⁶⁴ 条消息认证的高吞吐量场景,需要考虑其他方案。
六、GMAC
GMAC 是 GCM(Galois/Counter Mode)认证加密模式中的 MAC 组件。当 GCM 的明文输入为空——即只有附加认证数据(AAD)而没有加密负载时——GCM 退化为纯认证模式,这就是 GMAC。理解 GMAC 的核心在于理解 GHASH 函数和有限域算术。
GHASH 通用哈希
GHASH 是定义在 GF(2¹²⁸)——128 位二元扩展域上的通用哈希函数(Universal Hash Function)。给定哈希密钥 H(由 E_K(0) 导出)和输入块 X₁, X₂, …, Xₙ,GHASH 计算:
Y₀ = 0
Y₁ = (Y₀ ⊕ X₁) · H 在 GF(2¹²⁸) 上
Y₂ = (Y₁ ⊕ X₂) · H 在 GF(2¹²⁸) 上
...
Yₙ = (Yₙ₋₁ ⊕ Xₙ) · H 在 GF(2¹²⁸) 上
GHASH(H, X) = Yₙ
展开后可以看到,GHASH 实际上是在 GF(2¹²⁸) 上对多项式求值:
GHASH(H, X) = X₁ · Hⁿ ⊕ X₂ · Hⁿ⁻¹ ⊕ ... ⊕ Xₙ · H
这是一个关于 H 的多项式,每个系数 Xᵢ 就是对应的消息块。GF(2¹²⁸) 上的不可约多项式为 p(x) = x¹²⁸ + x⁷ + x² + x + 1,所有运算对此多项式取模。
GHASH 是一个 ε-almost universal(ε-AU)哈希函数族:对于任意两个不同的消息 M ≠ M’,当 H 均匀随机选取时,Pr[GHASH(H, M) = GHASH(H, M’)] ≤ n/2¹²⁸,其中 n 是较长消息的块数。这个界限来自代数基本定理——GF(2¹²⁸) 上的 n 次多项式至多有 n 个根。
从通用哈希到 MAC
GHASH 本身不是安全的 MAC——如果直接将 GHASH 的输出作为标签,攻击者只要知道一个消息-标签对就能恢复密钥 H(通过解多项式方程),然后伪造任意消息的标签。
GMAC 通过一次一密(One-Time Pad)的方式保护 GHASH 的输出:
GMAC(K, nonce, M) = GHASH(H, M || len) ⊕ E_K(nonce || 0³¹1)
其中 E_K(nonce || 0³¹1) 是一个随 nonce 变化的掩码值。只要 nonce 不重复,每条消息使用不同的掩码,GHASH 的输出就被有效地”加密”了。
这就是为什么 nonce 重用在 GCM/GMAC 中是灾难性的:如果两条不同消息使用了相同的 nonce,攻击者可以将两个标签异或消除掩码,得到 GHASH(H, M₁) ⊕ GHASH(H, M₂),然后通过 GF(2¹²⁸) 上的多项式运算恢复密钥 H,进而伪造任意消息的标签。
GMAC/GCM 的优势在于性能——GF(2¹²⁸) 乘法可以通过 PCLMULQDQ 等 CPU 指令高效实现,使得 GMAC 在支持这些指令的平台上比 HMAC 快得多。AES-NI 加上 PCLMULQDQ 使得 AES-GCM 成为目前最高效的认证加密方案之一,这也是 TLS 1.2 和 1.3 中 AES-GCM 被广泛部署的原因。
七、Poly1305
Poly1305 是 Daniel J. Bernstein 在 2005 年提出的一次性消息认证码(One-time Authenticator)。它与 ChaCha20 流密码配合使用,构成了 ChaCha20-Poly1305 认证加密方案——这是 TLS 1.3 的必选密码套件之一,也是 WireGuard VPN、SSH 等众多协议的首选方案。
数学构造
Poly1305 的核心是在素数域 GF(p) 上的多项式求值,其中 p = 2¹³⁰ - 5(一个梅森素数的近亲,其特殊形式使得模运算极其高效)。
给定 32 字节密钥 (r, s)(其中 r 和 s 各 16 字节),以及消息 M 分成的 16 字节块 c₁, c₂, …, cq,Poly1305 计算:
Poly1305(r, s, M) = ((c₁ · rq + c₂ · rq⁻¹ + ... + cq · r¹) mod p) + s mod 2¹²⁸
注意每个消息块 cᵢ 在转化为整数之前,会在最高位附加一个 1 比特——即 cᵢ = int(Mᵢ) + 2^(8·len(Mᵢ))。这个”哨兵位”的作用是防止全零块的歧义,确保不同的消息块序列产生不同的多项式系数。
密钥 r 需要满足特定的”钳位”(Clamping)条件:r 的某些位被强制设为 0。具体来说,r[3]、r[7]、r[11]、r[15] 的高 4 位被清零,r[4]、r[8]、r[12] 的低 2 位被清零。钳位的目的是确保 r 的值足够小且具有特定结构,这使得多精度乘法可以用更高效的方式实现(减少进位传播),同时不影响安全性——经过钳位后 r 的有效空间仍然足够大。
一次性语义
与 GMAC 类似,Poly1305 的多项式求值部分本身不足以构成安全的 MAC——如果攻击者知道足够多的消息-标签对且使用相同的 (r, s),就能通过代数方法恢复密钥。Poly1305 的安全性依赖于 s 的一次性使用:每条消息必须使用不同的 s 值。
在 ChaCha20-Poly1305 中,一次性密钥 (r, s) 通过 ChaCha20 的第一个输出块导出:用消息的 nonce 运行 ChaCha20,取输出的前 32 字节作为 Poly1305 的一次性密钥。不同的 nonce 产生不同的 (r, s),保证了一次性语义。
设计的优雅性
Poly1305 的设计体现了 Bernstein 追求的工程美学:
选择 p = 2¹³⁰ - 5 的精妙之处。模 2¹³⁰ - 5 的运算可以极其高效地实现:对 2¹³⁰ 取模时,高于 130 位的部分只需乘以 5 再加回低位——因为 2¹³⁰ ≡ 5 (mod p)。这使得在 64 位机器上,整个 Poly1305 计算可以用 5 个 64 位寄存器表示 130 位的中间值(每个寄存器存 26 位),乘法和归约都可以用普通的整数运算完成,不需要特殊的大数库。
常量时间实现的友好性。Poly1305 的所有运算——整数加法、乘法、模归约——都是数据无关的,天然适合常量时间实现。不需要条件分支,不需要查表,不需要任何可能导致侧信道泄漏的操作。
16 字节标签。Poly1305 的输出恰好是 16 字节(128 位),这是 MAC 标签的典型长度。没有浪费也没有截断,与底层素数域的大小完美匹配。
从安全性角度看,Poly1305 作为一次性 MAC 的安全界限是信息论的(Information-theoretic):对于单条消息,伪造成功的概率至多为 ⌈L/16⌉ · 2⁻¹⁰⁶,其中 L 是消息的字节长度。这个界限不依赖于任何计算复杂性假设,它来自有限域上多项式根的数量上界。
八、MAC 的常见误用
MAC 方案本身可能是安全的,但错误的使用方式会导致安全性完全崩溃。以下是实践中最常见的几类误用。
重放攻击
MAC 保证消息的完整性和认证性,但不保证新鲜性(Freshness)。攻击者可以截获一个合法的消息-标签对 (M, t),然后在稍后的时间重新发送它。由于标签仍然有效,接收方会接受这条”旧”消息。
在金融系统中,重放攻击可能导致一笔转账被执行多次。在认证系统中,重放攻击可能让攻击者重用一个过期的认证令牌。
防御重放攻击的标准方法包括: - 序列号/计数器:发送方在每条消息中包含递增的序列号,接收方拒绝序列号不递增的消息。 - 时间戳:消息中包含时间戳,接收方拒绝时间戳过旧的消息(需要松散的时钟同步)。 - Nonce/挑战-响应:接收方先发送一个随机的挑战值(nonce),发送方将 nonce 包含在消息中一起认证。
重要的是,这些防御措施中的序列号、时间戳或 nonce 必须被包含在 MAC 计算的输入中,否则攻击者仍然可以替换它们。
Encrypt-then-MAC vs MAC-then-Encrypt vs Encrypt-and-MAC
当同时需要机密性和完整性时,加密和 MAC 的组合顺序至关重要。三种可能的组合方式产生了截然不同的安全性结果:
Encrypt-and-MAC(E&M):C = Enc(K₁, M),t = MAC(K₂, M)。对明文分别加密和认证,将密文和标签一起发送。这是 SSH 协议使用的方式。问题在于:MAC 标签是对明文计算的,而 MAC 不要求隐藏明文的任何信息(MAC 的安全定义中没有机密性要求)。一个安全的 MAC 完全可以泄露明文的某些信息——例如,一个确定性 MAC 会泄露两条消息是否相同。虽然在实践中常用的 MAC(如 HMAC)通常不会泄露明文信息,但从理论角度讲,E&M 不保证 CPA 安全性。
MAC-then-Encrypt(MtE):先计算 t = MAC(K₂, M),然后 C = Enc(K₁, M || t)。对明文和标签一起加密。这是 TLS 1.2 及更早版本使用的方式。问题在于:接收方必须先解密才能验证 MAC。如果解密过程中的任何错误处理(例如填充错误)在时间上可观测,就可能导致填充预言攻击(Padding Oracle Attack)——这正是 BEAST、Lucky13、POODLE 等著名攻击的根源。Krawczyk 在 2001 年证明了如果底层加密方案是 CPA 安全的流密码,MtE 可以达到 CCA 安全性;但对于 CBC 模式加密,MtE 的安全性依赖于更强的假设,实践中容易出错。
Encrypt-then-MAC(EtM):C = Enc(K₁, M),t = MAC(K₂, C)。先加密,然后对密文计算 MAC。接收方先验证 MAC,只有验证通过后才解密。这是 IPsec ESP 使用的方式,也是密码学界的共识最佳实践。EtM 的优势在于:
- MAC 验证可以在解密之前进行。如果 MAC 验证失败,直接拒绝,根本不进行解密。这完全消除了填充预言攻击的可能性。
- Bellare 和 Namprempre 在 2000 年证明了:如果加密方案是 CPA 安全的,MAC 是 SUF-CMA 安全的,那么 EtM 组合达到 CCA 安全性(即 IND-CCA2)。这是一个无条件的结论,不依赖于加密方案的具体实现方式。
结论:在需要同时提供机密性和完整性的场景中,始终使用 Encrypt-then-MAC,或者更好地,直接使用成熟的认证加密方案(AEAD),如 AES-GCM 或 ChaCha20-Poly1305——它们在内部已经正确地将加密和认证融合在一起。
笔者认为,“先认证再加密”(MtE)与”先加密再认证”(EtM)的选择,是对称密码学工程中影响最深远的顺序决策。从理论上看,两者的安全性差异可以用精确的归约来描述;但在工程实践中,这个选择的后果远超理论分析所能覆盖的范围。MtE 迫使实现者在解密路径上处理未经认证的数据,这为侧信道打开了一扇窗户——而历史已经反复证明,这扇窗户几乎不可能完美关闭。从 Lucky13 到 POODLE,每一次攻击都源于同一个根本原因:解密发生在认证之前。EtM 则在架构层面消除了这类问题:未通过认证的密文根本不会进入解密路径。从工程实践来看,选择 EtM 不仅是一个密码学决策,更是一个系统设计决策——它决定了你的错误处理代码有多少机会变成攻击面。
时序攻击:非常量时间的 MAC 验证
MAC
验证的最后一步通常是比较两个字节序列:计算出的标签和收到的标签。如果使用标准的字节比较函数(如
C 的 memcmp 或 Python 的
==),比较会在发现第一个不同字节时立即返回。这意味着比较所花的时间取决于两个标签的共同前缀长度。
攻击者可以利用这个时序差异来逐字节猜测正确的标签。假设标签有 n 个字节。攻击者首先尝试 256 种可能的第一个字节,测量每种情况下服务器的响应时间。正确的第一个字节会导致比较函数检查第二个字节(稍微慢一点),而错误的第一个字节会导致立即返回(稍微快一点)。通过多次测量取统计平均值,攻击者可以区分这两种情况。然后对第二个字节重复此过程,依次类推。
在最坏情况下,暴力破解一个 n 字节标签需要 2⁸ⁿ 次尝试,但时序攻击将其降低到 256n 次——对于 32 字节标签,从 2²⁵⁶ 降低到 8192 次。
防御方法是使用常量时间比较(Constant-time Comparison):无论两个输入是否相同,比较操作始终花费完全相同的时间。典型的实现方式是对所有字节的异或结果进行按位或累积:
int constant_time_compare(const unsigned char *a,
const unsigned char *b, size_t len)
{
unsigned char result = 0;
size_t i;
for (i = 0; i < len; i++)
result |= a[i] ^ b[i];
return result == 0; /* 1 表示相等,0 表示不等 */
}这个函数始终遍历所有字节,result
中任何一位为 1
就表示存在差异。循环的迭代次数和每次迭代的操作完全与输入数据无关。
现代编程语言通常提供专用的常量时间比较函数:Python 的
hmac.compare_digest()、Go 的
crypto/subtle.ConstantTimeCompare()、Node.js 的
crypto.timingSafeEqual()。在任何涉及 MAC
验证的代码中,都应该使用这些函数而非普通的相等比较。
九、SipHash 与特殊用途 MAC
并非所有 MAC 需求都与网络通信中的消息认证相关。某些场景需要专门设计的、针对特定性能特征或安全属性优化的 MAC 方案。
SipHash:哈希表的守护者
SipHash 由 Jean-Philippe Aumasson 和 Daniel J. Bernstein 在 2012 年提出,其设计目标是:为短输入消息提供高效的、具有 PRF 安全性的 MAC,特别适用于哈希表的密钥哈希。
为什么哈希表需要密码学 MAC?在 Web 应用中,当用户提交的数据被存入哈希表时(例如 HTTP 请求的参数、JSON 对象的键),攻击者可以故意构造大量哈希冲突的键,使哈希表从 O(1) 退化为 O(n)。如果一次请求中包含数万个精心构造的键,哈希表操作的时间复杂度可以达到 O(n²),导致服务器 CPU 耗尽——这就是哈希洪泛攻击(Hash-flooding Attack,也称 HashDoS)。
2011 年,研究者发现几乎所有主流编程语言的哈希表实现都容易受到 HashDoS 攻击。问题的根源在于:这些语言使用的哈希函数(如 MurmurHash、CityHash)是非密码学的——它们追求速度但不追求不可预测性,攻击者可以通过分析哈希函数的代码来构造碰撞。
SipHash 的解决方案是:使用一个随机的 128 位密钥初始化哈希函数,使得攻击者无法预测任何输入的哈希值,因此无法构造碰撞。SipHash-2-4(2 轮压缩,4 轮终结)是标准配置,其性能对于短消息几乎与 MurmurHash 相当(在某些平台上甚至更快),但提供了密码学级别的不可预测性。
目前,SipHash 已被 Rust(默认的 HashMap 哈希函数)、Python(3.4+,默认的字符串哈希函数)、Ruby、Perl、Swift、Linux 内核等众多语言和系统采用为默认的哈希表哈希函数。
KMAC:基于 Keccak 的 MAC
KMAC(Keccak Message Authentication Code)是 NIST SP 800-185 标准化的 MAC 方案,基于 SHA-3 的底层置换 Keccak-p。与 HMAC 不同,KMAC 不需要嵌套哈希的技巧——因为 SHA-3 基于海绵构造(Sponge Construction)而非 Merkle-Damgård 构造,天然免疫长度扩展攻击。
KMAC 利用 cSHAKE(SHA-3 的可定制化扩展输出函数)实现:
KMAC(K, M, L, S) = cSHAKE(bytepad(encode_string(K), rate) || M || right_encode(L), L, "KMAC", S)
其中 L 是期望的输出长度,S 是可选的定制化字符串。KMAC 的优势包括: - 无需嵌套调用,单遍处理,效率略高于 HMAC。 - 输出长度可变(XOF 性质),可以直接输出任意长度的密钥材料。 - 正式支持定制化字符串进行域分离。
未来方向
密码学 MAC 领域仍在持续演进。一些值得关注的方向包括:
后量子安全 MAC。MAC 的安全性主要依赖于密钥的保密性。Grover 算法可以将暴力搜索密钥的复杂度从 2ⁿ 降至 2ⁿ/²,因此在量子计算时代,256 位密钥的 MAC(如 HMAC-SHA256)仍提供 128 位安全性——这在实践中是足够的。因此,与公钥密码学不同,对称 MAC 面临的量子威胁相对较小,主要的应对措施是将密钥长度加倍。
轻量级 MAC。物联网(IoT)设备和 RFID 标签等资源极度受限的环境需要超轻量级的 MAC 方案。Ascon(2023 年 NIST 轻量级密码学竞赛的获胜者)提供了基于海绵构造的认证加密和哈希方案,特别适合这类场景。其底层置换只需要极少的门电路就能在硬件中实现。
加密硬件加速。随着 ARM 的 SHA-3 指令、Intel 的 SHA 扩展指令等硬件加速能力的普及,基于不同哈希函数的 MAC 方案在性能上的差距正在缩小。选择 MAC 方案时,硬件平台的加速能力越来越成为一个重要的考量因素。
MAC 选型矩阵
在实际工程中,面对多种 MAC 方案,正确的选型需要综合考量性能、密钥管理复杂度、误用风险等维度。以下矩阵提供了一个快速参考:
| 方案 | 速度 | 密钥敏捷性 | 是否需要 Nonce | 误用时的伪造风险 | 推荐搭配 |
|---|---|---|---|---|---|
| HMAC-SHA256 | 中等 | 高(任意长度密钥) | 否 | 低——确定性,无状态 | 通用场景、HKDF、TLS PRF |
| CMAC-AES | 中等 | 中(需 AES 密钥) | 否 | 低——但存在 2^64 生日界限 | 仅有 AES 硬件的嵌入式环境 |
| GMAC | 极高(硬件加速) | 中(需 AES 密钥) | 是 | 灾难性——nonce 重用泄露 H 密钥 | AES-GCM AEAD 内部使用 |
| Poly1305 | 极高 | 低(一次性密钥) | 是(隐含于一次性语义) | 灾难性——密钥重用可代数恢复 | ChaCha20-Poly1305 AEAD |
值得注意的是,GMAC 和 Poly1305 在上表中的”伪造风险”列被标记为”灾难性”,这并非它们本身不安全,而是它们的安全性模型严格依赖 nonce 的唯一性。在 AEAD 方案内部使用时,nonce 管理由上层协议保证,这些方案表现优异;但如果将它们作为独立 MAC 使用而忘记 nonce 约束,后果不堪设想。从工程实践来看,如果你不确定自己的场景是否能保证 nonce 唯一性,HMAC 是最安全的默认选择——它没有状态、没有 nonce、没有让你犯错的空间。
综合来看,MAC 作为对称密码学的基础原语,其重要性不亚于加密本身。没有认证的加密是危险的——密文的可篡改性曾经导致了无数实际系统的安全漏洞。在现代密码学实践中,正确的做法是使用经过验证的认证加密方案(AEAD),而不是手动组合加密和 MAC。但理解 MAC 的原理——从形式化安全定义到具体构造,从安全性证明到工程实践中的陷阱——是每一位安全工程师和密码学从业者的必备知识。
密码学百科系列 · 第 11 篇
← 上一篇:密码学哈希函数 | 系列目录 | 下一篇:认证加密(AEAD) →