密钥总要换。轮换时旧数据怎么办?什么时候才能删旧密钥?主密钥轮换的成本怎么降?并发读写时怎么避免读到一半的状态?这篇用真实的 AES-GCM 实现密文格式、信封加密与并发轮换,逐项量出结果。
轮换前(只有 key 1):密文 45 字节,头部的 key id = 1,能解密:订单 #1001 轮换后(新增 key 2,当前 = 2,key 1 仍在钥匙环里):新密文的 key id = 2;旧密文(key id = 1)仍能解密:订单 #1001;新密文能解密:订单 #1003 过早地从钥匙环里删除 key 1 之后,解密旧密文 → NoSuchElementException: 没有 key id 1 对应的密钥(已被删除或从未存在) 惰性重加密(读到旧密文时解密再用当前密钥加密回写):旧密文 key id 1 → 新密文 key id 2;之后可以安全删除 key 1(所有密文都重加密完成之后) 篡改头部里的 key id(把 2 改成别的 id,key id 同时是 AAD 与选钥依据)→ NoSuchElementException: 没有 key id 258 对应的密钥(已被删除或从未存在) 把 key id 改成 1(密文实际是 key 2 加密的)→ AEADBadTagException: Tag mismatch
50000 条各 1 KB 的记录:直接用主密钥加密 388 ms;信封加密(每条记录一个 DEK,KEK 包裹 DEK)279 ms 轮换主密钥:直接加密方案要对每条记录 解密 + 重新加密 215 ms(处理 53 MB 数据);信封加密只需对每条记录的 40 字节包裹后的 DEK 解包 + 重新包裹 71 ms,数据体不动——快 3 倍 KEK 轮换之后,用新 KEK 解包的 DEK 解密原来的数据体 → 内容一致 用新 KEK 去解旧的包裹 DEK → InvalidKeyException: javax.crypto.IllegalBlockSizeException: Integrity check failed
一个线程不停地轮换 key 1 ↔ 2(共 102651099 次),4 个线程各做 60000 次「读当前 id 与密钥 → 加密 → 用钥匙环按头部 key id 解密」: 两个字段分别读取(id 与 key 不是同一时刻):成功 230314,失败 9686(4.04%)——密文头部写的 id 与实际使用的密钥不一致 读取一个不可变的钥匙环快照(AtomicReference<Keyring>,id 与密钥一起换):成功 240000,失败 0
单线程加密 256 字节小消息(含生成随机 nonce、创建 Cipher):2.0 μs/条,约 510329 条/秒 随机 96 位 nonce 的 GCM,单个密钥最多加密 2^32 = 4.29e+09 条消息(NIST 建议值,生日界):按单线程最高速度 510329 条/秒算,2.3 小时用完;按 1000 条/秒算 50 天;按 100 条/秒算 497 天 所以轮换周期应该同时由两个条件决定:时间(泄露窗口)与消息数(nonce 预算),取先到者;消息数需要有计数(或用确定性的计数器 nonce) 钥匙环里有 3 个版本时解密一条消息 1.6 μs/条(按 key id 查表,几乎不增加成本)