做了五年半的开发者,我碰过最多的加密需求就是 AES。从最初的 ECB 一行代码完事,到后来被安全扫描打回重写,再到在现代 .NET 生态里老老实实用 GCM,这条路几乎是每个做数据安全的人都要走一遍的。这篇就围绕 AES 加密模式演进这条主线,把我这几年在 C# 里从 ECB、CBC 到 GCM 的实操经验、踩坑记录和代码样板一次整理出来。适合刚接手加密需求的后端开发,也适合被审计要求搞到头大、准备从旧模式迁移到认证加密的同学。
1. 加密模式演进背后的逻辑:为什么 ECB、CBC 最终都要走向 GCM
1.1 分组密码与工作模式:先搞懂 AES 到底加密了什么
AES 本身属于分组密码,一次只能处理固定长度的数据块,AES 的分组长度固定是 16 字节。无论要加密的是用户密码、身份证号,还是一整份 JSON 文件,实际使用时都要先把数据切成一个个 16 字节的块,再逐块交给 AES 处理。
分组密码原生的操作单元只有“一个块”,但真实业务里没有人只加密 16 字节。于是就有了工作模式(Mode of Operation),它决定了多个分组怎么串联、怎么加盐、怎么防止同一份明文在不同位置产生相同密文。可以把 AES 当成一把质量很好的锁,工作模式则是“用这把锁锁一排柜子”的方式。ECB 是每个柜子都单独锁,锁法完全一样;CBC 是锁完上一个柜子后,结果会影响下一个柜子的锁法;GCM 则在锁柜子的同时,给整排柜子加了一道封条,谁动过都会留痕。
AES 的密钥长度还会影响轮数:128 位密钥的 AES-128 是 10 轮,192 位是 12 轮,256 位是 14 轮。这里不展开数学细节,实践上默认选 AES-256。至于工作模式,绝大部分新代码应该直接考虑认证加密,也就是 AEAD,GCM 就是目前最常见的 AEAD 选择。
1.2 ECB 模式:简单到让人放松警惕,也危险到不该再碰
ECB 的工作方式最直白:每个 16 字节明文块独立使用相同的密钥加密,块与块之间没有任何关联。加密阶段可以完全并行,解密也是,代码写起来一行就能搞定。
但问题恰好出在这个“独立”上。既然每个块都用同一个密钥独立加密,那么同样的明文块,在任意位置加密后都会得到一模一样的密文块。这不是数学故障,而是模式本身保留了大量明文统计特征。我看过一个非常直观的测试:一张大片纯色块的图片用 ECB 加密后,视觉轮廓依然清晰可辨。换成一段结构固定的业务报文,重复的头部字段照样会暴露。
C# 里实现 ECB 非常简单:
using System.Security.Cryptography; byte[] key = RandomNumberGenerator.GetBytes(32); byte[] plaintext = Encoding.UTF8.GetBytes("这段内容不应保留统计特征"); using var aes = Aes.Create(); aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.PKCS7; aes.Key = key; using var encryptor = aes.CreateEncryptor(); byte[] ciphertext = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);ECB 连 IV 都不需要,因为没有“链”。看起来少了参数、少了出错机会,实际上是把最重要的随机性丢掉了。任何生产系统都不应该把 ECB 作为默认选项。如果只是本地测试算法链路,可以用;一旦数据会跨网络传输、会落库、会涉及多用户,请直接绕开它。
1.3 CBC 模式:链式反馈虽然解决了重复块,却不解决完整性
CBC 引入了一条链:每个明文块在加密之前,先与上一个密文块做异或,然后再交给 AES。第一个明文块没有“上一个块”,于是引入一个初始向量 IV。
因为有这条链,相同的明文块在不同位置会得到不同的密文块,ECB 的重复模式问题被解决了。CBC 解密则是反向操作:先解出当前块,再与上一个密文块异或,因此解密天然支持并行,加密则在链上串行。这一点在后面对比性能时非常重要。
CBC 的隐患在于:它只提供保密性,不提供完整性。攻击者可以修改第 N 个密文块,从而让第 N+1 个明文块产生可控变化。配合某些语言里解密时的 padding 报错,甚至能发展出 padding oracle 攻击,逐字节还原出明文。所以裸 CBC 根本不够,真正的生产方案应该是 CBC + HMAC,也就是先加密,再对密文做消息认证码。
用 C# 写 CBC 也很常规:
byte[] key = RandomNumberGenerator.GetBytes(32); byte[] iv = RandomNumberGenerator.GetBytes(16); byte[] plaintext = Encoding.UTF8.GetBytes("CBC 示例数据"); using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = key; aes.IV = iv; using var encryptor = aes.CreateEncryptor(); byte[] ciphertext = encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);这段代码本身没问题,但留着“裸 CBC”在项目里,就像给门上了锁却把钥匙挂在门框上。正常做法是在外面再套 HMAC,而“套 HMAC”这件事,恰恰是团队最容易做错的地方。
1.4 GCM 模式:把加密和认证放到同一个框架里
GCM(Galois/Counter Mode)是把 CTR 模式加密和 GHASH 认证组合在一起。先由 nonce 生成初始计数器,通过 AES 加密计数器得到密钥流,明文与密钥流异或得到密文;与此同时,GHASH 在伽罗华域上对密文和附加数据 AAD 计算认证标签。最终输出的包除了密文,还会多出一段固定长度的 tag。
GCM 最关键的特点是同时解决保密性、完整性和真实性。解密时会重新计算 tag 并与随包携带的 tag 比对,一旦密文、nonce、AAD 任何一个字节被篡改,验证立刻失败,不会给你任何“部分解密结果”。这就是认证加密 AEAD 带来的好处:把“加密”和“防篡改”两件事合并成了一件事。
在 .NET 里,从 .NET Core 3.0 开始提供AesGcm类,到 .NET 5/6 之后 API 逐渐稳定。和手动组合 CBC + HMAC 相比,GCM 让普通开发者在默认情况下就能写出安全方案。但 GCM 也有一个绝对红线:nonce 绝不能复用。同一把密钥下,两次加密用了同一个 nonce,密钥流就会重复,攻击者能够通过异或关系直接破坏保密性,甚至进一步伪造认证标签。这一点我在 5.3 节还会再强调。
2. C# 落地 AES 实践:从 Aes 类到 AesGcm 类的关键差异
2.1 密钥、IV 与随机数生成:第一步最容易埋雷
不管是 ECB、CBC 还是 GCM,第一行代码几乎都是生成密钥、生成随机数。这里有一个很多人踩过的坑:用new Random()或Random.NextBytes生成密钥。Random不是密码学安全的随机数生成器,生成出的字节序列理论上可预测,一旦密钥被还原,整个加密体系直接失效。
正确做法是用RandomNumberGenerator.GetBytes:
byte[] key = RandomNumberGenerator.GetBytes(32); byte[] iv = RandomNumberGenerator.GetBytes(16); // CBC 用 byte[] nonce = RandomNumberGenerator.GetBytes(12); // GCM 用- 密钥长度:推荐 32 字节,也就是 AES-256。
- CBC 的 IV:固定需要 16 字节,与 AES 分组等长。
- GCM 的 nonce:标准推荐 12 字节,正好 96 位,这是 GCM 最推荐的长度。如果用 16 字节 nonce,要通过 GHASH 转换,存在些许额外风险,实践上没必要。
密钥不应该硬编码在源码里,更不应该提交到 Git。可以从环境变量、密钥管理服务或配置中心读取。开发环境可以用本地生成密钥,生产环境必须走可轮换的密钥管理通道。我们后面封装服务时,会直接让密钥从外部传入,而不是在类内部写死。
还有一个细节:IV 和 nonce 不需要保密,可以随着密文一起传输,但绝不能固定不变。固定 IV 会让 CBC 下相同明文始终生成相同密文,本质上是把 CBC 退化成了 ECB 的行为模式。
2.2 ECB 与 CBC 的 C# 实现:兼容旧系统的必经之路
虽然新系统应该用 GCM,但很多存量系统还在运行 CBC,甚至 ECB。为了兼容旧数据,你仍然需要知道怎么用Aes类写这些模式。核心就是设置Mode和Padding。
封装一个比较完整的 AES-CBC 加密方法:
public static byte[] EncryptCbc(byte[] plaintext, byte[] key, byte[] iv) { using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = key; aes.IV = iv; using var encryptor = aes.CreateEncryptor(); return encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length); } public static byte[] DecryptCbc(byte[] ciphertext, byte[] key, byte[] iv) { using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = key; aes.IV = iv; using var decryptor = aes.CreateDecryptor(); return decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length); }这里有个值得解释的点:Aes.Create()返回实例后,默认模式其实已经就是 CBC,默认填充也是 PKCS7,但显式写出来更容易读,也避免后期有人误改成别的模式。
如果你还要兼容 ECB,只需要把aes.Mode = CipherMode.ECB,并且不设置IV。但请只在读取旧数据时使用,不要在写入路径上新增 ECB 数据。每次你在代码里看到CipherMode.ECB,都应该把它当成一个待偿还的技术债。
2.3 AesGcm 的正确用法:.NET 中真正推荐的模式
AesGcm的 API 和传统Aes类差别很大。它不是用一个TransformFinalBlock把数据“流式”处理完,而是把加密后的密文和认证标签分别放到两个输出缓冲区里。
基础用法:
using System.Security.Cryptography; using System.Text; byte[] key = Convert.FromBase64String("base64-encoded-key"); byte[] nonce = RandomNumberGenerator.GetBytes(12); byte[] plaintext = Encoding.UTF8.GetBytes("需要保护的内容"); using var aesGcm = new AesGcm(key, 16); byte[] ciphertext = new byte[plaintext.Length]; byte[] tag = new byte[16]; aesGcm.Encrypt(nonce, plaintext, ciphertext, tag); // 解密 byte[] decrypted = new byte[ciphertext.Length]; aesGcm.Decrypt(nonce, ciphertext, tag, decrypted);注意几点:
AesGcm的第二个参数是 tag 长度,单位是字节。官方允许 12、13、14、15、16 字节,但实践中直接用 16 字节,也就是 128 位认证标签。ciphertext的长度必须和plaintext完全一致,因为 GCM 本质是流式异或,密文长度等于明文长度。Decrypt如果验证失败,会抛出AuthenticationTagMismatchException,而不返回错误码。生产封装时,通常会在这一层捕获并转换成业务返回值。- 如果要在 .NET Core 3.0 上运行,
AesGcm构造函数只支持new AesGcm(key),默认 tag 就是 16 字节。从 .NET 5 开始才提供带tagSizeInBytes的构造函数。
2.4 CBC+HMAC 方案为什么我最终弃用了
有人会问:既然老系统已经在用 CBC,那我只要在外面加一层 HMAC,不也能实现认证吗?理论上可以,而且 encrypt-then-MAC 是一种经典安全结构。但组合方案里容易犯的错误非常多:
第一,加密密钥和 HMAC 密钥必须分离。直接用同一个密钥既做 AES 又做 HMAC,属于高危操作。正确的做法是通过 HKDF 从主密钥派生出两个独立子密钥,很多人直接忽略。
第二,HMAC 校验必须使用常量时间比较。用普通字符串比较或数组比较,可能被时间侧信道攻击,攻击者可以通过响应时间差异逐字节猜出 MAC。.NET 里应该用CryptographicOperations.FixedTimeEquals。
第三,HMAC 计算范围很容易漏。有些人只对密文做 HMAC,没有覆盖 IV 或关联元数据,攻击者可以替换 IV 或改变字段含义而不触发告警。
第四,组合方案是把“安全责任”分摊给写代码的人,而 GCM 把这些细节收进标准库里。同样的精力,放到密钥管理和 nonce 管理上,收益高得多。
所以我自己的结论是:新代码默认 GCM;CBC+HMAC 只作为兼容层存在,不为新功能引入。
3. 安全、性能与选型的横向对比:别再迷信“高级加密”
3.1 安全维度上的分水岭
从安全角度,这三种模式的差异是质的,不是量的。下面这张表可以贴在项目文档里:
| 模式 | 保密性 | 完整性 | 典型风险 | 推荐程度 |
|---|---|---|---|---|
| ECB | 弱 | 无 | 相同明文产生相同密文,模式可被识别 | 不应使用 |
| CBC | 中 | 无 | 需要额外 HMAC,否则可篡改、可 padding oracle | 仅在旧系统 |
| GCM | 强 | 有 | nonce 复用会导致灾难 | 新系统默认 |
关键在于“完整性”这一列。没有完整性保护时,攻击者不需要拿到密钥,只要拦截密文并修改,就可能造成业务逻辑被篡改。比如一份密文代表转账金额,CBC 下攻击者通过翻转某些位,可能把金额改成可控值。GCM 的 tag 就是用来拦截这种操作的。
GCM 的已知薄弱点比较集中:nonce 复用、AAD 使用错误、tag 被截断。这些问题都可以通过规范设计避免,整体风险比前两者低很多。
3.2 性能与并行化:GCM 并没有想象中的慢
很多人觉得 GCM 又是加密又是认证,肯定会比 CBC 慢。实际性能取决于硬件指令和并行方式。
- ECB 加密并行度最高,因为块之间完全独立。
- CBC 加密是串行的,因为每一块都要依赖上一块密文;但解密时可以并行。
- CTR 模式的加密解密都可以并行,GCM 继承了这个优点,加密和解密都能预先生成密钥流。
- GHASH 认证部分在现代 CPU 上有
PCLMULQDQ指令支持,配合 AES-NI,GCM 的性能在实际项目中非常可观。
如果你做基准测试,在支持 AES-NI 的机器上,AES-GCM 通常不会成为性能瓶颈。真正的性能开销往往来自网络传输、数据库读写、序列化,而不是这十几字节的 tag 计算。所以选型时我一直坚持:先用安全把模式定下来,再考虑优化,不要反过来为了“少几毫秒”用 ECB。
3.3 场景选型建议:什么情况下我能接受 CBC,什么情况坚决不碰
根据我的实践,场景可以这样划分:
- 新项目、新接口、新数据库表:一律 AES-GCM。
- 需要和第三方系统对接,第三方只支持 CBC:可以接受 CBC,但必须在协议层做 HMAC 或签名,最好由对方提供明确的认证方案。
- 老系统迁移:先兼容读旧数据,再逐步切到 GCM,不要一改了之。
- 数据量极小比如 token、设备序列号:一样用 GCM,别因为数据“不敏感”就放松。
- 合规审计或安全评审:直接选 GCM,并且把 nonce 策略写清楚,评审通过率明显更高。
ECB 我认为没有任何新业务场景应该使用。即使是加密随机值,ECB 也会暴露“相同随机值出现”的信息,这是多余的泄露。
4. 实操全过程:用 C# 封装一个健壮的 AES-GCM 加密工具
4.1 明确封包格式:为未来演进留下空间
真实项目里,我们不能只把密文返回给调用方,否则解密时 nonce、tag 无从说起。常见的做法是自定义一个封包格式,把密文、nonce、tag、版本信息一起打包。
我设计的封包格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 2 字节 | 固定标记,便于识别包类型 |
| Version | 1 字节 | 版本号,用于未来算法演进 |
| Nonce | 12 字节 | GCM nonce |
| Tag | 16 字节 | GCM 认证标签 |
| Ciphertext | 变长 | 实际密文,长度与明文一致 |
把 Magic 和 Version 作为 AAD 传到AesGcm.Encrypt,解密时再校验它们。这样做的好处是:如果有人把密文包里的版本号从 0x01 改成 0x02,tag 校验就会失败,因为 AAD 变了。这能有效防止降级攻击和字段混淆。
4.2 完整代码实现与逐段解读
下面是我自己在项目中使用的封装版本,做了一些简化,但结构完整,可以直接抄:
using System; using System.Security.Cryptography; public sealed class AesGcmService : IDisposable { private const int NonceSize = 12; private const int TagSize = 16; private const int HeaderSize = 3; private static readonly byte[] Magic = { 0x01, 0x0A }; private const byte Version = 0x01; private readonly byte[] _key; private bool _disposed; public AesGcmService(byte[] key) { _key = key ?? throw new ArgumentNullException(nameof(key)); if (key.Length != 32) throw new ArgumentException("AES-256 需要 32 字节密钥", nameof(key)); } public static byte[] GenerateKey() => RandomNumberGenerator.GetBytes(32); public byte[] Encrypt(byte[] plaintext) { ThrowIfDisposed(); var nonce = RandomNumberGenerator.GetBytes(NonceSize); var ciphertext = new byte[plaintext.Length]; var tag = new byte[TagSize]; var header = BuildHeader(); using (var aes = new AesGcm(_key, TagSize)) { aes.Encrypt(nonce, plaintext, ciphertext, tag, header); } var result = new byte[HeaderSize + NonceSize + TagSize + ciphertext.Length]; Buffer.BlockCopy(header, 0, result, 0, header.Length); Buffer.BlockCopy(nonce, 0, result, HeaderSize, nonce.Length); Buffer.BlockCopy(tag, 0, result, HeaderSize + NonceSize, tag.Length); Buffer.BlockCopy(ciphertext, 0, result, HeaderSize + NonceSize + TagSize, ciphertext.Length); return result; } public byte[]? Decrypt(byte[] package) { ThrowIfDisposed(); if (package.Length < HeaderSize + NonceSize + TagSize) return null; var header = new byte[HeaderSize]; var nonce = new byte[NonceSize]; var tag = new byte[TagSize]; var ciphertextLength = package.Length - HeaderSize - NonceSize - TagSize; var ciphertext = new byte[ciphertextLength]; Buffer.BlockCopy(package, 0, header, 0, header.Length); Buffer.BlockCopy(package, HeaderSize, nonce, 0, nonce.Length); Buffer.BlockCopy(package, HeaderSize + NonceSize, tag, 0, tag.Length); Buffer.BlockCopy(package, HeaderSize + NonceSize + TagSize, ciphertext, 0, ciphertextLength); if (!CryptographicOperations.FixedTimeEquals(header, BuildHeader())) return null; var plaintext = new byte[ciphertextLength]; try { using var aes = new AesGcm(_key, TagSize); aes.Decrypt(nonce, ciphertext, tag, plaintext, header); } catch (AuthenticationTagMismatchException) { return null; } return plaintext; } private static byte[] BuildHeader() { return new byte[] { Magic[0], Magic[1], Version }; } private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(AesGcmService)); } public void Dispose() { if (!_disposed) { CryptographicOperations.ZeroMemory(_key); _disposed = true; } } }这个类有几点值得展开:
- 构造函数要求密钥长度必须是 32 字节,这是强制 AES-256,避免调用方传入短密钥而不自知。
Encrypt里每次都要重新生成 nonce,保证同一把密钥下几乎不可能复用。Decrypt的返回值是byte[]?,当包格式不对或认证失败时返回null,业务层不用手工捕获细粒度异常。CryptographicOperations.FixedTimeEquals用来比较 header,防止时间侧信道。header 是公开字段,这个比较不是必需的,但养成用常量时间比较的习惯没坏处。Dispose里清空密钥字节。虽然托管内存里可能还有残留副本,但至少能降低被转储的风险。
使用示例:
byte[] key = AesGcmService.GenerateKey(); using var service = new AesGcmService(key); byte[] encrypted = service.Encrypt(Encoding.UTF8.GetBytes("你好,GCM")); byte[] decrypted = service.Decrypt(encrypted); string text = Encoding.UTF8.GetString(decrypted!);4.3 单元测试与兼容性验证思路
封装完之后,不要急着接入业务,先写几个针对性测试:
- 加密再解密,得到的内容必须与原文完全一致。
- 对密文任意翻转一个字节,解密必须返回
null,而不是抛异常或返回乱码。 - 手动修改包里的版本字节,解密必须返回
null,因为 header 是 AAD。 - 对同一段明文连续加密两次,生成的封包必须不同,因为 nonce 不同。
- 空明文也要测:
Encrypt(Array.Empty<byte>())应该返回长度为 31 的封包,解密后得到空数组。 - 长明文要测:比如 1MB 随机字节,确保不会因为缓冲区长度问题崩溃。
兼容性验证上,要注意运行框架。AesGcm不是 .NET Framework 4.8 的标准 API,如果项目还在老框架上,需要用 BouncyCastle 等第三方库实现 GCM。迁移的时候不要直接把代码复制过去,要单独做一轮跨平台加解密验证,确认你在 .NET 8 里加密的包,能被 BouncyCastle 正确解开;反过来也一样。跨语言对接时尤其要确认 nonce、tag 的拼接顺序,不同库返回的顺序可能有差异。
5. 常见问题与排查技巧实录
5.1 Padding 模式与块对齐:为什么明文同样长度密文却不同
GCM 模式下不需要设置 Padding,因为密文长度等于明文长度,任何多余字节都会破坏解密。而使用 ECB/CBC 的TransformFinalBlock时,默认 PKCS7 会在最后一个块补齐到 16 字节。如果明文恰好是 16 的倍数,PKCS7 也会额外追加一个完整的 16 字节填充块。这不是 bug,是为了解密时能明确判断填充长度。所以有些场景下,同样 16 字节明文,CBC 加密后得到 32 字节,初学者会觉得奇怪。
调试时如果发现 GCM 封包长度比预期多出 28 字节,先算账:3 字节 header + 12 字节 nonce + 16 字节 tag = 31 字节。如果密文长度还是多出 31,说明确实是 GCM 封包;如果只是多出 16,可能是误用了 CBC 的 PKCS7。
5.2 解密乱码的排查:IV 保存与传递问题
我遇到过不少“加密没问题、解密乱码”的情况,最后定位基本都是 IV 或 nonce 保存错了。比如加密时随机生成了 nonce,但只把密文存进数据库,解密时却用了一个全零 nonce。GCM 虽然不会因为 nonce 错误而抛异常,但解密结果会完全不同。
排查建议:
- 确认 nonce 是否在加密时被放入封包,并原样取出。
- 确认没有做双重编码:比如先对密文做 Base64,再整体拼接,再用 Base64,导致切分错位。
- 确认
Buffer.BlockCopy的偏移量正确,尤其是封包里有 tag 时,tag 和密文的先后顺序容易搞错。 - 用固定的测试向量验证:已知密钥、已知 nonce、已知明文,比对官方示例输出,确认算法链路没问题。
5.3 标签验证失败的几个诱因
AuthenticationTagMismatchException是所有 GCM 开发者迟早要面对的问题。它表示解密时重新计算的 tag 和传入的 tag 不一致。常见原因有:
- 密文在传输或存储中被截断、改位。
- 解密时传入的 AAD 和加密时不一致。比如加密时把版本号作为 AAD,但解密时没有传,或者后面对应不上。
- nonce 不一致。同一个封包,加密时用 nonce A,解密时用了 nonce B。
- tag 切分位置不对。从封包取 tag 时,偏移量少算或多算了一个字节。
- 密钥不一致。换过密钥但旧数据还用旧密钥解,必须按照版本进行密钥路由。
- tag 长度不匹配。某些库默认 16 字节 tag,你自己把 tag 存储为 12 字节,解密时长度不对。
排查时不要只盯着“是不是网络传输坏了”,先用Decrypt方法分别尝试“篡改密文”“篡改 AAD”“篡改 nonce”三个实验,确认哪一类变化触发了失败,往往很快就能定位到逻辑错误。
5.4 从旧系统迁移到 GCM 的经验
迁移旧系统比新写一个 GCM 工具麻烦得多。我的经验是分四步走:
第一,兼容读旧数据。上线后先保证代码能解密历史数据,旧 ECB/CBC 格式继续保留,新写入的数据一律用 GCM。可以靠封包里的 Magic 或 Version 区分格式。
第二,数据库字段扩容。GCM 的封包长度是明文长度加 31,而 CBC 密文长度基本是明文长度按 16 字节填充后再加一个可能存在的块,两者不相等。如果原字段长度是固定varbinary(256),要先改成varbinary(max)或至少varbinary(512),否则新数据会写不进去。
第三,尽量在迁移期间轮换密钥。GCM 本身很好,但如果继续沿用当年硬编码在代码里的老密钥,安全性提升有限。上线迁移时,同时切换新的密钥管理方案。
第四,对另一端的解析方同步升级。如果密文要发给第三方系统,一定提前确认对方是否支持 AEAD,以及对方封包格式里的 nonce、tag 顺序是否需要做转换。我见过两个系统各自实现了 GCM,但一个把 nonce 放在最前面,另一个把 tag 放在最前面,结果联调时各种不匹配。
我个人踩过几次坑之后,最大的体会是加密方案真不在于炫技,而在于让错误发生的概率降到最低。GCM 没有把所有问题解决掉,但它把认证功能内置,让普通开发者不会因为组合 HMAC 时顺序错漏而埋下隐患。如果你手头还有 ECB 或者裸 CBC 的代码,别等安全扫描来提醒,今天就可以按文中的方案重构一版。哪怕只是把解密部分先兼容好,也是向前迈了一大步。