对称密码的终点站:一次操作同时锁上机密性与完整性。这是你在 Android 上唯一该选的对称加密。
上一章我们发现一个洞:CBC、CTR 都只保机密、不保完整,攻击者能在不知道密钥的情况下篡改密文。补这个洞,就是这一章的全部内容——而补法有讲究,补错了照样翻车。
要检测篡改,我们需要第 1 章说的完整性 + 认证。工具是 MAC(消息认证码):用一把密钥对密文算一个"防伪封印"(叫 tag / 认证标签),接收方重算一遍,对不上就说明数据被动过。
AEAD(带关联数据的认证加密)把"加密"和"认证"打包成一个原语,一步到位。它接收:
解密时,任何一个字节——密文、nonce、AAD——只要被改动一位,标签校验就失败,解密函数直接报错、不返回任何明文。这就堵死了上一章的所有篡改攻击和 padding oracle:接收方要么拿到完整无误的明文,要么什么都拿不到。
AES-GCM:AES 的 CTR 模式(第 8 章)负责加密,再用一个叫 GHASH 的运算生成认证标签。有 AES 硬件加速时极快,是 TLS 1.3 的首选之一。
ChaCha20-Poly1305:ChaCha20(第 6 章)负责加密,Poly1305 负责认证。在没有 AES-NI 的手机上通常比 GCM 更快、更抗时间侧信道。
两者安全性相当,选哪个主要看平台。Android 现代 API 两者都支持。
下面的 Demo 用的是浏览器原生的 WebCrypto AES-GCM——不是教学模拟,是你的浏览器真刀真枪跑的那套加密。流程:输入明文 → 加密得到 密文 || 标签 → 你在密文里翻转任意一个字节 → 点解密。看看会发生什么。
AEAD 解决了完整性,但它给你递了一把上膛的枪:AES-GCM 在同一密钥下重复使用 nonce,是灾难级的错误——比第 5 章的两次一密还糟。
为什么更糟?因为 GCM 底层是 CTR 流密码,nonce 重用首先触发我们熟悉的两次一密攻击(C₁⊕C₂ = P₁⊕P₂,明文泄露)。但不止于此:GCM 的认证标签也依赖 nonce 的唯一性,nonce 一重复,攻击者还能恢复出 GHASH 的认证密钥——一旦拿到它,攻击者就能对任意伪造的密文计算出合法的标签,完整性保护彻底失效。机密性和完整性一起崩塌。
GCM 的 nonce 只有 96 位。两种安全做法:①随机 nonce——每次加密用 SecureRandom 生成 12 字节。但要注意生日界限:同一把密钥加密约 2³² 条消息后,随机碰撞的概率开始变得不可忽略,所以随机 nonce 有消息数量上限。②计数器 nonce——维护一个严格递增、持久化、绝不回退的计数器。危险恰恰在"持久化且不回退":设备重启、恢复备份、多实例并发,都可能让计数器倒退并重复。如果你拿不准,优先考虑随机 nonce,并在文档里写清消息量上限;需要海量消息时,可用 XChaCha20-Poly1305(192 位 nonce,随机碰撞实际不可能)。
把这一 Phase 的全部知识浓缩成一段你可以照抄的代码。加密用 AES-GCM,nonce 随机生成并随密文一起存/传:
// 密钥应来自 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:我们从 XOR 起步,理解了完美但不实用的一次一密,用流密码和分组密码把它拉回现实,学会了正确的模式,最后用 AEAD 把机密性与完整性焊死。现在,只要 Alice 和 Bob 已经共享一把密钥,他们就能在 Mallory 眼皮底下安全通信。
可是——那把共享密钥,当初是怎么安全送到对方手里的?这个第 1 章就埋下的"鸡生蛋"难题,对称密码始终无法回答。要解开它,需要一场真正的革命:公钥密码学。但在那之前,我们得先补上另一块拼图——不加密、却同样撑起半个密码学世界的哈希函数。下一个 Phase 见。
AES/GCM/NoPadding + 系统随机 IV + 让解密异常直接抛出。