密语 · CIPHER | 目录 CH.09 / 26
绝密
Phase 2 · 对称密码

AEAD 认证加密

对称密码的终点站:一次操作同时锁上机密性与完整性。这是你在 Android 上唯一该选的对称加密。

阅读 ~14 分钟 前置 第 8 章 Demo AES-GCM 篡改检测(真实 WebCrypto)

上一章我们发现一个洞:CBC、CTR 都只保机密、不保完整,攻击者能在不知道密钥的情况下篡改密文。补这个洞,就是这一章的全部内容——而补法有讲究,补错了照样翻车。

一、加密之外,还要盖个"防伪封印"

要检测篡改,我们需要第 1 章说的完整性 + 认证。工具是 MAC(消息认证码):用一把密钥对密文算一个"防伪封印"(叫 tag / 认证标签),接收方重算一遍,对不上就说明数据被动过。

AEAD(带关联数据的认证加密)把"加密"和"认证"打包成一个原语,一步到位。它接收:

解密时,任何一个字节——密文、nonce、AAD——只要被改动一位,标签校验就失败,解密函数直接报错、不返回任何明文。这就堵死了上一章的所有篡改攻击和 padding oracle:接收方要么拿到完整无误的明文,要么什么都拿不到。

两大主流 AEAD

AES-GCM:AES 的 CTR 模式(第 8 章)负责加密,再用一个叫 GHASH 的运算生成认证标签。有 AES 硬件加速时极快,是 TLS 1.3 的首选之一。
ChaCha20-Poly1305:ChaCha20(第 6 章)负责加密,Poly1305 负责认证。在没有 AES-NI 的手机上通常比 GCM 更快、更抗时间侧信道。
两者安全性相当,选哪个主要看平台。Android 现代 API 两者都支持。

二、亲手篡改一段密文,看它当场报警

下面的 Demo 用的是浏览器原生的 WebCrypto AES-GCM——不是教学模拟,是你的浏览器真刀真枪跑的那套加密。流程:输入明文 → 加密得到 密文 || 标签 → 你在密文里翻转任意一个字节 → 点解密。看看会发生什么。

DEMO AES-GCM 篡改检测
对比一下:如果这是第 8 章的 CTR 模式,翻转密文字节会让对应明文字节悄悄改变、不报任何错。而 GCM 会当场拒绝——这就是"认证"的价值。

三、致命陷阱:GCM 的 nonce 绝不能重复

AEAD 解决了完整性,但它给你递了一把上膛的枪:AES-GCM 在同一密钥下重复使用 nonce,是灾难级的错误——比第 5 章的两次一密还糟。

为什么更糟?因为 GCM 底层是 CTR 流密码,nonce 重用首先触发我们熟悉的两次一密攻击(C₁⊕C₂ = P₁⊕P₂,明文泄露)。但不止于此:GCM 的认证标签也依赖 nonce 的唯一性,nonce 一重复,攻击者还能恢复出 GHASH 的认证密钥——一旦拿到它,攻击者就能对任意伪造的密文计算出合法的标签,完整性保护彻底失效。机密性和完整性一起崩塌。

nonce 到底该怎么给?

GCM 的 nonce 只有 96 位。两种安全做法:①随机 nonce——每次加密用 SecureRandom 生成 12 字节。但要注意生日界限:同一把密钥加密约 2³² 条消息后,随机碰撞的概率开始变得不可忽略,所以随机 nonce 有消息数量上限。②计数器 nonce——维护一个严格递增、持久化、绝不回退的计数器。危险恰恰在"持久化且不回退":设备重启、恢复备份、多实例并发,都可能让计数器倒退并重复。如果你拿不准,优先考虑随机 nonce,并在文档里写清消息量上限;需要海量消息时,可用 XChaCha20-Poly1305(192 位 nonce,随机碰撞实际不可能)。

四、Android 开发者的正确姿势

把这一 Phase 的全部知识浓缩成一段你可以照抄的代码。加密用 AES-GCM,nonce 随机生成并随密文一起存/传:

Kotlin · Android AES-GCM 正确用法
// 密钥应来自 Android Keystore(见第 20 章),这里仅示意
val key: SecretKey = /* 256-bit AES key from Keystore */

fun encrypt(plaintext: ByteArray, aad: ByteArray): ByteArray {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key)         // 让系统生成随机 IV
    val iv = cipher.iv                            // 12 字节 nonce,非秘密
    cipher.updateAAD(aad)                          // 认证但不加密的头部
    val ct = cipher.doFinal(plaintext)            // 密文已含 16 字节 tag
    return iv + ct                                // 把 IV 拼在前面一起存
}

fun decrypt(blob: ByteArray, aad: ByteArray): ByteArray {
    val iv = blob.copyOfRange(0, 12)
    val ct = blob.copyOfRange(12, blob.size)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
    cipher.updateAAD(aad)
    return cipher.doFinal(ct)  // 篡改则抛 AEADBadTagException —— 务必让它抛,别 catch 后返回半个明文
}
三条一定要记住的规矩

① 用 AES/GCM/NoPadding 或 ChaCha20-Poly1305,永远不要 AES(默认 ECB)或 AES/CBC 裸用。② 让系统生成随机 IV,把它和密文拼在一起存;永远不要硬编码 IV。③ 解密抛异常时就是被篡改了——直接失败,不要吞掉异常返回部分数据。这三条能挡住第 22 章里一大半的真实事故。

Phase 2 小结:我们能安全地锁数据了……如果双方已有密钥

回望这一 Phase:我们从 XOR 起步,理解了完美但不实用的一次一密,用流密码和分组密码把它拉回现实,学会了正确的模式,最后用 AEAD 把机密性与完整性焊死。现在,只要 Alice 和 Bob 已经共享一把密钥,他们就能在 Mallory 眼皮底下安全通信。

可是——那把共享密钥,当初是怎么安全送到对方手里的?这个第 1 章就埋下的"鸡生蛋"难题,对称密码始终无法回答。要解开它,需要一场真正的革命:公钥密码学。但在那之前,我们得先补上另一块拼图——不加密、却同样撑起半个密码学世界的哈希函数。下一个 Phase 见。

本章要点

  • CBC/CTR 只保机密,AEAD 把加密与认证打包,一步同时保证机密性与完整性。
  • 主流是 AES-GCM 与 ChaCha20-Poly1305;AAD 可认证不加密的头部;篡改任一字节都会解密失败。
  • GCM 的 nonce 绝不能在同一密钥下重复——否则明文泄露,且认证密钥被恢复,完整性也崩。
  • Android 正确姿势:AES/GCM/NoPadding + 系统随机 IV + 让解密异常直接抛出。