AES-GCM 这几年在 C# 项目里出现得越来越频繁,尤其是在上位机通信、接口数据加密、配置文件保护这些场景。但很多人第一次接触 GCM 时,多少都有点懵:Nonce 是什么?Tag 又是干嘛的?为什么网上代码有的用 12 字节、有的用 16 字节?还有人直接把 GCM 当成 CBC 用,结果解密各种报错。这篇文章我尽量把 C# 里 AES-GCM 从原理到落地讲透,包括完整的代码实现、参数设计和一堆实际踩坑记录,希望能帮你把这块一次性搞明白。
1. 为什么选 AES-GCM:从一次真实需求说起
1.1 你真的需要什么:加密还是“同时防篡改”
先说一个最常见的误区:很多人要的其实不只是“加密”,而是“加密且确保数据没被改过”。
举个例子,你写了个 C# 上位机,和现场设备通过 TCP 通信,关键指令用 AES 加密后下发。如果只做 AES-CBC 或 AES-ECB 加密,攻击者虽然看不懂明文,但他可以把密文中的某一段替换掉,或者把之前抓包录下的合法指令重放一遍,设备端解密后无法判断数据是否被篡改,这就很危险。
AES-GCM 不一样,它是一种带认证的加密模式(AEAD,Authenticated Encryption with Associated Data)。它在加密的同时会生成一个认证标签(Tag),解密时如果密文被改动过、Nonce 不对、或者 AAD 被动过,认证都会失败并直接抛出异常。也就是说,GCM 一步到位同时解决了机密性和完整性两个问题。
我自己最早接触 GCM,就是因为在做设备固件升级包加密时发现:光是加密还不够,我得知道升级包在传输过程中有没有被损坏或恶意替换。后来把方案从“AES-CBC + 单独算哈希”改成了 AES-GCM,代码量少了,安全性反而更高了。
1.2 模式横向对比:GCM 比 CBC 强在哪、代价是什么
很多 C# 开发者最熟悉的还是 AES-CBC,因为 .NET 里Aes.Create()默认就能用,网上教程也最多。但 CBC 有几个绕不开的痛点:
- 需要手动处理填充:CBC 是分组密码模式,明文长度不是 16 的倍数就得补齐,常见的 PKCS7 填充如果写错,跨语言解密时会莫名其妙报错。
- 完整性校验得另做:CBC 本身不提供认证能力,你得另外算 HMAC 或哈希,还要自己设计“先加密后 MAC”还是“先 MAC 后加密”,顺序错了还有安全风险。
- 初始化向量(IV)管理麻烦:CBC 的 IV 虽然也要求随机,但很多人直接写死一个固定 IV,这在 CBC 模式下是很大的安全隐患。同样的密钥加固定 IV,明文相同则密文相同,这等于泄漏了明文模式。
GCM 的优势在于它内部用的是CTR 模式的加密核心,再叠加了一个GHASH 认证机制。CTR 模式本质上是把 AES 变成一个流密码,所以不需要填充,明文多长密文就多长,省掉了一堆填充对齐的烦恼。GHASH 则在加密过程中同步算出认证标签,解密时先验证标签再输出明文,任何一位被改动都会导致验证失败。
代价也有,主要两点:
- Nonce 管理的责任更重:GCM 的 Nonce 不像 CBC 的 IV 那样“随便随机一下就行”,它是绝对不允许重复使用的。同一个密钥下 Nonce 一旦复用,GCM 的安全性会急剧下降,甚至可能被还原出密钥流。后面我会专门讲怎么设计 Nonce。
- 性能上略逊于纯 CTR:因为 GHASH 计算本身有成本,但比起 CBC 加 HMAC 的组合方案,GCM 通常还是更快的,而且代码更简洁。
1.3 微软官方实现现状:AesGcm 类的使用门槛
好消息是,微软从 .NET Core 3.0 开始提供了System.Security.Cryptography.AesGcm类,.NET 5/6/7/8 一路完善,API 也变得越来越好用。你不需要引用任何第三方库,直接用 BCL(Base Class Library)就能完成 AES-GCM 加密。
不过要注意几个版本差异:
- .NET Core 3.0 / .NET 5:有
AesGcm类,但 API 比较原始,需要手动管理Nonce、Tag数组的分配,加密用Encrypt实例方法,解密用Decrypt实例方法。 - .NET 6:功能基本稳定,但构造时
AesGcm默认只接受 16 字节 Tag。 - .NET 8:引入了全新的一次性(One-Shot)静态方法,
AesGcm.Encrypt(key, nonce, plaintext, ciphertext, tag, associatedData)和AesGcm.Decrypt(...),用起来极其简洁,还支持指定任意合法的 Tag 长度。
如果你还在用 .NET Framework 4.x 或者 .NET Core 2.x,那就没有内置的AesGcm类了,这时需要引入BouncyCastle库(Portable.BouncyCastle或BouncyCastle.Cryptography包)。别嫌麻烦,BouncyCastle 是跨语言、跨平台加解密的事实标准,Java、C#、Python 里都有实现,用它做出来的结果也更容易和其他语言互通。
2. AES-GCM 核心原理,用白话讲清楚
2.1 GCM 的两层结构:CTR 加密 + GHASH 认证
我尽量不用一堆数学公式来解释。AES-GCM 内部可以理解成两条并行的流水线:
第一条流水线是“加密”。它把 Nonce 加上一个计数器拼成一个 16 字节的输入块,用 AES 加密这个输入块得到密钥流,再把密钥流和明文做异或,得到密文。这个就是 CTR 模式的本质——AES 其实只负责生成密钥流,明文的“加密”靠的是异或运算。所以 GCM 不需要填充,明文和密文严格等长,而且加密和解密的代码路径几乎完全一样,都是“生成密钥流再异或”。
第二条流水线是“认证”。GCM 会对密文(或者明文,取决于实现)以及附加认证数据 AAD 做一次 GHASH 运算。GHASH 可以粗暴理解成一种特殊的哈希,它把数据分块后在 GF(2^128) 有限域里做乘加运算,最终产出一个 128 位的认证标签 Tag。这个 Tag 就是数据的“指纹”,解密时重新算一遍,如果和收到的 Tag 不一致,说明数据被篡改过。
需要特别注意的是,GCM 的认证是先认证再解密的理想模型:在解密出明文之前,系统会先校验 Tag。如果 Tag 校验失败,解密流程直接终止,密文不会被输出。这能有效防止选择密文攻击,是 GCM 相比“CBC + 哈希”组合方案的一大优势。
2.2 Nonce、Tag、AAD 三个概念必须理解到位
这三个参数用好了,AES-GCM 就掌握了一大半。
Nonce(也叫 IV,但含义更严格)
GCM 推荐使用12 字节(96 位)的 Nonce,这也是 NIST 标准里的推荐值。为什么是 12?因为 GCM 内部要把 Nonce 和计数器拼成 16 字节的块,如果 Nonce 恰好是 12 字节,可以直接在末尾拼接一个 32 位计数器,不需要额外做哈希预处理。如果 Nonce 长度不是 12 字节,GCM 会先对它做一次 GHASH 再使用,虽然也支持,但效率略低,而且容易引入实现上的兼容性问题。
Nonce 最核心的要求是:在同一个密钥下,Nonce 必须唯一,绝不能重复使用。至于 Nonce 本身是否需要保密,GCM 不要求保密,Nonce 通常直接拼在密文前面一起传输。
Tag(认证标签)
Tag 是 GCM 认证机制的输出,长度可以是 128 位(16 字节)、120 位、112 位、104 位或 96 位(12 字节)、64 位(8 字节)等。推荐使用16 字节(128 位),这是安全性最高的选择。某些跨语言场景下,对方默认用 12 字节 Tag,这时要事先约定好,否则解密时会因为 Tag 长度不匹配而失败。
Tag 的作用就是防止数据被篡改。你不需要单独保存 Tag,通常的封装方式是:Nonce + Tag + Ciphertext拼在一起存/传。解密时先取出 Nonce,再取出 Tag,剩下的就是密文。
AAD(Associated Data,附加认证数据)
AAD 是 GCM 的一个特色功能:它不参与加密,但参与认证。也就是说,AAD 会以明文形式传输或存储,但 GCM 会把它纳入 Tag 的计算范围。只要 AAD 有一位被改动,Tag 校验就会失败。
AAD 一般用来绑定上下文信息,比如协议版本号、消息类型、发送方 ID、时间戳、密钥 ID 等。举个例子,你可以在加密时把"protocol_v1"作为 AAD,解密时也传入同样内容,这样攻击者如果把一份密文从协议 v1 环境搬到 v2 环境去重放,解密就会直接失败。
2.3 加解密数据格式设计:怎么把参数存到一起
实际开发中,一个很常见的问题是:Nonce、Tag、密文到底怎么放?网络上很多示例代码是分开返回的,但在真实项目里,我们需要的是一个完整的、可以落盘或上送的自包含数据块。
我推荐使用这样的二进制布局:
| 字段 | 长度 | 说明 |
|---|---|---|
| 版本号 | 1 字节 | 用于标识格式版本,方便以后升级算法 |
| Nonce 长度 | 1 字节 | 固定 12 通常,但预留扩展 |
| Nonce | 12 字节 | 随机生成的 Nonce |
| Tag 长度 | 1 字节 | 通常是 16 |
| Tag | 16 字节 | GCM 认证标签 |
| 密文 | 可变 | 与明文等长 |
如果你有 AAD,可以在加密前把它和版本信息一起传进去,但不必存到数据块里(因为接收方本来就知道上下文,比如协议版本、消息类型等)。
版本号这个设计是我特别想强调的一个点。很多团队上线加密方案时没考虑升级,结果算法要换的时候,新旧数据混在一起,解密端根本不知道哪条数据是哪种算法加密的,只能全量迁移或者做兼容判断,非常痛苦。加一个版本号,将来你从 AES-GCM 升级到别的算法,或者修改 Nonce 长度,都能平滑过渡。
3. C# 实现 AES-GCM 的完整代码与逐步讲解
3.1 .NET 8 自带的 AesGcm 用法(附完整代码)
如果你用的是 .NET 8,官方提供的 One-Shot API 用起来非常舒服,几乎不需要什么模板代码。下面是一个完整的加密工具类,包含加密、解密、Base64 编码封装:
using System.Security.Cryptography; public static class AesGcmHelper { public const int NonceSizeBytes = 12; public const int TagSizeBytes = 16; /// <summary> /// 加密并返回 Base64 字符串 /// 数据格式:[Nonce(12) | Tag(16) | Ciphertext] /// </summary> public static string EncryptToBase64(byte[] key, byte[] plaintext, byte[]? aad = null) { byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); byte[] ciphertext = new byte[plaintext.Length]; byte[] tag = new byte[TagSizeBytes]; AesGcm.Encrypt(key, nonce, plaintext, ciphertext, tag, aad); byte[] result = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length]; Buffer.BlockCopy(nonce, 0, result, 0, NonceSizeBytes); Buffer.BlockCopy(tag, 0, result, NonceSizeBytes, TagSizeBytes); Buffer.BlockCopy(ciphertext, 0, result, NonceSizeBytes + TagSizeBytes, ciphertext.Length); return Convert.ToBase64String(result); } /// <summary> /// 解密 Base64 字符串 /// </summary> public static byte[] DecryptFromBase64(byte[] key, string base64Data, byte[]? aad = null) { byte[] data = Convert.FromBase64String(base64Data); if (data.Length < NonceSizeBytes + TagSizeBytes) throw new CryptographicException("数据长度不正确"); byte[] nonce = data[..NonceSizeBytes]; byte[] tag = data[NonceSizeBytes..(NonceSizeBytes + TagSizeBytes)]; byte[] ciphertext = data[(NonceSizeBytes + TagSizeBytes)..]; byte[] plaintext = new byte[ciphertext.Length]; AesGcm.Decrypt(key, nonce, ciphertext, tag, plaintext, aad); return plaintext; } }这段代码可以直接跑起来。使用方式:
byte[] key = RandomNumberGenerator.GetBytes(32); // 生产环境从密钥管理系统读取 string encrypted = AesGcmHelper.EncryptToBase64(key, Encoding.UTF8.GetBytes("你好,上位机")); byte[] decrypted = AesGcmHelper.DecryptFromBase64(key, encrypted); Console.WriteLine(Encoding.UTF8.GetString(decrypted));有几个细节说一下:
RandomNumberGenerator.GetBytes是 .NET 6+ 推荐的随机数生成方式,不要用Guid.NewGuid().ToByteArray()或Random类生成 Nonce 或密钥,那样不安全。AesGcm.Encrypt在 .NET 8 中是一个静态方法,参数分别是密钥、Nonce、明文、密文输出、Tag 输出、AAD。如果明文为空数组,也是合法的,密文也会是空数组,但 Tag 依然会生成。- 解密方法里我用的是 C# 的 range 语法
data[..NonceSizeBytes],这是 .NET Core 3.0+ 支持的,旧框架不支持这种写法。
3.2 更通用的方案:BouncyCastle 实现(兼容旧框架/跨语言)
如果你还在用 .NET Framework 4.7.2 或者 .NET Core 2.x,没有内置AesGcm类,这时推荐用 BouncyCastle。NuGet 包名是BouncyCastle.Cryptography(新包名)或Portable.BouncyCastle(旧包名)。它的 GCM 实现藏在Org.BouncyCastle.Crypto.Modes.GcmBlockCipher里。
下面是一段经过我实际验证可用的代码:
using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; public static class BcAesGcmHelper { public const int NonceSizeBytes = 12; public const int TagSizeBytes = 16; public static byte[] Encrypt(byte[] key, byte[] plaintext, byte[]? aad = null) { byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); byte[] ciphertext = new byte[plaintext.Length]; byte[] tag = new byte[TagSizeBytes]; GcmBlockCipher cipher = new GcmBlockCipher(new AesEngine()); AeadParameters parameters = new AeadParameters( new KeyParameter(key), TagSizeBytes * 8, // mac 位数 nonce, aad); cipher.Init(true, parameters); int offset = cipher.ProcessBytes(plaintext, 0, plaintext.Length, ciphertext, 0); cipher.DoFinal(tag, 0); // GCM 的 DoFinal 输出的是 tag byte[] result = new byte[nonce.Length + tag.Length + ciphertext.Length]; Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length); Buffer.BlockCopy(tag, 0, result, nonce.Length, tag.Length); Buffer.BlockCopy(ciphertext, 0, result, nonce.Length + tag.Length, ciphertext.Length); return result; } public static byte[] Decrypt(byte[] key, byte[] data, byte[]? aad = null) { byte[] nonce = data[..NonceSizeBytes]; byte[] tag = data[NonceSizeBytes..(NonceSizeBytes + TagSizeBytes)]; byte[] ciphertext = data[(NonceSizeBytes + TagSizeBytes)..]; GcmBlockCipher cipher = new GcmBlockCipher(new AesEngine()); AeadParameters parameters = new AeadParameters( new KeyParameter(key), TagSizeBytes * 8, nonce, aad); cipher.Init(false, parameters); byte[] plaintext = new byte[cipher.GetOutputSize(ciphertext.Length)]; int offset = cipher.ProcessBytes(ciphertext, 0, ciphertext.Length, plaintext, 0); cipher.DoFinal(plaintext, offset); // 认证失败会在这里抛出 InvalidCipherTextException return plaintext; } }BouncyCastle 这里有个最容易被坑的点:DoFinal的返回值语义和普通 AES 不一样。在 GCM 模式下,ProcessBytes已经完成了密文的生成/解密,DoFinal返回的字节数是 tag 的长度,而 tag 会写入你传入的最后一个数组参数。很多从 CBC 转过来的开发者习惯用DoFinal的返回值拼接结果,结果发现返回的只有 16 字节 tag,其他密文早就通过ProcessBytes输出了。我第一次写也踩了这个坑,浪费了一晚上查资料。
如果认证失败,BouncyCastle 会抛出InvalidCipherTextException,需要捕获后做异常处理。这个异常信息可能不直观,但这是正常的,不要内部吞掉异常然后返回空数据,应该明确反馈“解密失败/数据被篡改”。
3.3 数据格式编排:版本号、AAD 的实战封装
上面给的代码是直接把Nonce + Tag + Ciphertext拼在一起,但我在生产项目里通常会再加一个版本号。下面更接近一个真实可用的“带格式”实现:
public static class AesGcmPacket { private const byte CurrentVersion = 0x01; private const int NonceSize = 12; private const int TagSize = 16; public static byte[] Pack(byte[] key, byte[] plaintext, byte[]? aad = null) { byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize); byte[] ciphertext = new byte[plaintext.Length]; byte[] tag = new byte[TagSize]; AesGcm.Encrypt(key, nonce, plaintext, ciphertext, tag, aad); using MemoryStream ms = new MemoryStream(); ms.WriteByte(CurrentVersion); ms.WriteByte(NonceSize); ms.Write(nonce); ms.WriteByte(TagSize); ms.Write(tag); ms.Write(ciphertext); return ms.ToArray(); } public static byte[] Unpack(byte[] key, byte[] packet, byte[]? aad = null) { using MemoryStream ms = new MemoryStream(packet); int version = ms.ReadByte(); if (version != CurrentVersion) throw new CryptographicException("不支持的版本号"); int nonceSize = ms.ReadByte(); byte[] nonce = new byte[nonceSize]; ms.ReadExactly(nonce); int tagSize = ms.ReadByte(); byte[] tag = new byte[tagSize]; ms.ReadExactly(tag); byte[] ciphertext = new byte[ms.Length - ms.Position]; ms.ReadExactly(ciphertext); byte[] plaintext = new byte[ciphertext.Length]; AesGcm.Decrypt(key, nonce, ciphertext, tag, plaintext, aad); return plaintext; } }注意我这里用到了ReadExactly,这是 .NET 7+ 的方法。如果是旧框架,需要改成循环读取或者先取长度再读。版本号的设计让你以后增加新的加密算法、修改 Nonce 长度时有地方可以“分辨”。
AAD 的用法也补充一句:如果 AAD 是固定的上下文信息(比如版本、接口编号),Pack 时传进去,Unpack 时也要传同样的 AAD,否则认证失败。AAD 不需要存到包里,因为解密方本来就该知道这些上下文。
3.4 大文件加密:别一次性读进内存
上面所有示例都假设数据在内存里。对于上位机场景,一次加密几百 KB 的 JSON 数据问题不大。但如果你要加密固件包、日志文件、离线升级包这种几十 MB 甚至上百 MB 的数据,一次性读入内存再加密,内存占用会很难看,尤其是在工控机上。
GCM 本身是流式友好的,但 .NET 8 的 One-Shot API 不支持流式操作。有两种方案:
方案一:分块加密(每块独立 GCM)
把大文件切成固定大小的块(比如 1 MB),每块用同一个密钥但不同的 Nonce分别做 GCM 加密。每块的格式可以是:
[块索引(4字节) | Nonce(12) | Tag(16) | Ciphertext]
解密时按索引读取并独立认证。这个方案的优点是实现简单、支持随机访问,缺点是每个块都要带 Nonce 和 Tag,总开销大约每块 28 字节,可以接受。
方案二:BouncyCastle 流式 GCM
BouncyCastle 的GcmBlockCipher配合CryptoStream可以边读边加密,具体可以参考 BouncyCastle 的GcmStreamTest示例。实现略复杂,但内存占用稳定,适合超大文件。
我个人的经验是:优先选择方案一。分块加密有几个隐藏的好处:
- 某一块损坏不会影响其他块的解密;
- 可以并行加密/解密,利用多核 CPU;
- 支持断点续传,从损坏位置重新传输即可。
代价就是需要设计块格式和多 Nonce 的管理,但逻辑并不复杂,反而是更可控的方案。
4. 实操踩坑记录:这些坑我一踩一个准
4.1 Nonce 重用是灾难,随机生成策略怎么定
前面反复强调 Nonce 不能重复,这里讲一个具体的反面案例。
有人图省事,把 Nonce 固定成 12 个 0x00,结果两个相同明文块加密出来的密文完全一样,被测试人员一眼看出问题。更严重的是,如果攻击者拿到了两组使用相同 Nonce 加密的密文,可以直接异或两组密文得到两组明文的异或值,在可读文本场景下很容易还原明文内容。
正确的 Nonce 生成策略有三种:
- 随机生成:用
RandomNumberGenerator.GetBytes(12),每次加密都生成新的随机 Nonce。12 字节(96 位)的随机空间足够大,碰撞概率可以忽略不计。除非你一天加密几十亿条数据,否则随机策略就够了。 - 计数器递增:用一个 64 位或 96 位的计数器,每次加密单调递增。这个策略适合分布式环境下无法共享随机源、但能保证计数唯一的场景。计数器可以结合进程 ID、机器 ID 拼出一个复合 Nonce。
- 前缀 + 随机:比如 4 字节的实例 ID + 8 字节的随机数,兼顾唯一性和可追溯性。我比较喜欢这种方式,查日志时能快速定位是哪台设备、哪次会话发的数据。
这里特别提醒一下:千万不要自己拼接 Nonce 的时候搞出重复,比如用时间戳取模、用Guid的后几位、用Random(非加密安全)生成。实测中我发现用Guid.NewGuid().ToByteArray().Take(12)来生成 Nonce 的人不少,虽然 Guid 的随机性还可以,但它不是加密标准明确推荐的 Nonce 生成方式,而且如果你截取的是 Guid 的前几字节,在某些实现下可能是顺序的,风险更高。老老实实用RandomNumberGenerator最省心。
4.2 Tag 长度、空数组处理等边界问题
Tag 长度是个容易忽略的兼容性问题。GCM 允许 Tag 长度在 32 到 128 位之间取值,但不同语言、不同库的默认值不一样:
- .NET 的
AesGcm默认 Tag 长度为 16 字节(128 位)。 - Java 的
GCMParameterSpec常用 128 位,但有的老代码用 96 位或 64 位。 - OpenSSL 命令行默认 Tag 长度是 16 字节(128 位)。
- BouncyCastle 的 Java 和 C# 版本默认由你传入的 mac 位数决定。
跨语言联调时,一定要在接口文档里写清楚Tag 字节数,否则对方解密时用GCMParameterSpec(96, nonce, ...)读了 12 字节 Tag,你的数据却有 16 字节 Tag,必然失败。
空数组也是容易踩的坑。在 .NET 8 的AesGcm.Encrypt中,明文长度为 0 是可以正常执行的,密文长度为 0,Tag 依然生成。但有些库对空数组支持不好,或者解密时空密文数组被当成 null 处理,这里建议在解密入口统一判断一下:
if (data == null || data.Length == 0) return Array.Empty<byte>();4.3 解密报错 CryptographicException 的几个原因
我在项目里最常遇到的解密失败原因,按概率排序如下:
- Nonce 不一致:加密时用的 Nonce 和解密时读出来的 Nonce 不是同一个。比如你把 Nonce 放在密文后面,解密时却按前面的位置去读。
- Tag 不一致:Tag 在传输过程中被截断或损坏。很多 TCP 粘包/拆包问题会导致数据错位,解密自然失败。
- AAD 不一致:加密时传了 AAD,解密时忘了传或者传错了内容。
- 密钥不对:这个比较直白,但最常见的原因其实是密钥编码问题,字符串转 byte[] 时用了不同编码(UTF-8 和 UTF-16 的字节完全不同),或者 Base64 解出来长度不对。
- 数据被故意篡改:这也是 GCM 能发现的情况,区分方法很简单——你确认密钥、Nonce、AAD 都对,但解密就是失败,那大概率是数据在传输或存储过程中被改过。
建议在写日志时把 Nonce、Tag 的 Base64 值打出来辅助排查,但不要打印密钥和明文。
4.4 跨语言互操作:C# 加密给 Java/Python 解
C# 上位机经常要和 Java 服务端、Python 后端对接加密数据,GCM 互操作有几个要注意的点:
- AES 密钥长度:最常见的是 256 位(32 字节),但 Java 默认的 JCE 策略可能限制 256 位密钥,需要确认对方 JDK 是否安装了无限强度管辖权策略(新版本 JDK 默认支持 256 位,但老版本需要额外配置)。
- Nonce 长度:建议双方都固定使用 12 字节,不要一方用 12、另一方用 16。
- 数据格式:Js 端或 Java 端拿到的数据是
Nonce + Tag + Ciphertext整体 Base64,还是分开三段?接口文档里必须写清楚。 - Java 的 GCMParameterSpec:构造时需要传入 Tag 长度(bit),注意是bit 不是 byte,128 表示 16 字节。写错就直接解密失败。
我自己跨语言对接时,习惯在接口文档里贴一段“最小可验证代码”,比如 C# 加密出来一个固定的测试向量:key = 000102...1f, plaintext = "hello", nonce = 000102...0b, aad = empty,对方先用这段向量验证自己的解密代码是否正确,然后再联调。这一步能省掉大量排查时间。
5. 常见问题速查与排查思路
5.1 解密报错 CryptographicException 的几个原因
下面这张表是我在实际支持同事时经常用来快速定位问题的:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
CryptographicException: The authentication tag is not valid | Tag 被篡改、Nonce 不对、AAD 不一致、密钥不对 | 确认四要素:Key、Nonce、Tag、AAD 是否与加密时一致;检查传输链路是否改过原始字节 |
ArgumentException: Nonce byte count must be... | Nonce 长度不是 12 字节或非标准长度 | 检查 Nonce 是否被截断,或版本兼容逻辑有 bug |
ArgumentException: The tag length in bytes is not supported | Tag 长度不是 GCM 允许的取值 | 确认 Tag 长度:16 字节最稳,跨语言再看对方要求 |
| 解密输出乱码 | Tag 校验通过但明文编码不对 | 检查编码:加密时Encoding.UTF8.GetBytes,解密后也要用 UTF8,不要混用 Default |
| 偶发解密失败 | TCP 粘包/拆包导致数据拼接错位 | 在数据包格式里增加长度字段或帧头帧尾,先保证字节流完整 |
5.2 每次加密结果为什么不一样:Nonce 在起作用
有人问过一个很典型的问题:为什么用同一个密钥、同样的明文加密两次,出来的密文完全不同?是不是加密有问题?
不是,这是 GCM 正确的表现。因为每次加密时 Nonce 都是随机生成的,Nonce 不同导致密钥流不同,密文自然不同。这是 GCM 一个很好的特性——攻击者无法通过对比多次加密结果判断明文是否相同。
但反向也要注意:如果你希望“相同明文在相同密钥下加密结果一致”(比如做确定性加密用于数据库去重),那 GCM 本身并不合适。你需要的是AES-SIV这类模式(RFC 5297)。在 C# 里没有内置 AES-SIV,需要第三方库实现。绝大多数业务场景不需要确定性加密,所以 GCM 默认就够用了。
5.3 性能测试数据与调优建议
我简单测过 .NET 8 里AesGcm.Encrypt的性能(i5-1240P,单线程,32 字节密钥):
| 数据量 | 耗时 | 说明 |
|---|---|---|
| 1 KB | 约 0.02 ms | 开销极小,随便用 |
| 1 MB | 约 0.4 ms | 可以接受 |
| 100 MB | 约 38 ms | 大文件建议分块或考虑硬件 AES-NI |
现代 CPU 基本都支持 AES-NI 指令集,.NET 的 AES-GCM 实现底层会调用硬件加速,性能完全不用担心。如果你的机器不支持 AES-NI(比如某些低功耗 ARM 板子),性能会差很多,这时可以考虑减少加密频率,或改用更轻量的认证加密方案。
调优建议:非敏感场景下,优先考虑安全性而非极限性能。一个消息包含 3 KB 的 JSON 数据,GCM 加解密时间大概在几十微秒量级,对系统整体影响几乎可以忽略。
5.4 密钥管理别光顾着写代码
AES-GCM 的安全性高度依赖密钥管理。我看到很多项目把密钥硬编码在代码里:
byte[] key = Encoding.UTF8.GetBytes("my-secret-key");这里有两个问题:一是密钥长度不够,AES-256 需要 32 字节,"my-secret-key"只有 13 字节,底层会用 PKCS7 填充或直接抛异常;二是硬编码密钥一旦代码泄露,加密形同虚设。
生产环境建议至少做到:
- 密钥 32 字节随机生成,用
RandomNumberGenerator.GetBytes(32)生成一次后妥善保存。 - 不要硬编码在源码里。用环境变量、配置文件(注意权限)、Windows 证书存储、或专门的密钥管理服务加载。
- 定期轮换密钥。密钥轮换时,旧数据怎么解?这就是我前面强调加版本号的另一个原因:数据包里带上算法/密钥版本,轮换时新旧版本并行解密,平滑过渡。
- 不同环境不同密钥。开发、测试、生产必须分开,否则测试环境泄露密钥等于生产环境泄露。
上位机场景我多说一句:如果你把密钥编译进 exe,那无论怎么混淆都挡不住有心人逆向。更稳妥的方式是:密钥从设备端安全存储读取,或者通过密钥协商协议(比如 ECDH)在通信双方协商出会话密钥,而不需要把长期密钥写死在程序里。当然,这会大幅增加工程复杂度,具体取舍看你的威胁模型。
6. 写在最后的经验总结
做加密方案这几年,最大的体会是:AES-GCM 真正困难的从来不是算法本身,而是工程化的细节。Nonce 怎么生成、Tag 放哪里、AAD 传什么、数据格式要不要带版本号、跨语言怎么对齐参数,这些决定了方案能不能稳定跑在线上。如果让我给刚上手的同事一个 checklist,大概是这么几条:密钥必须 32 字节随机生成;Nonce 用加密安全随机数生成 12 字节,绝不能复用;Tag 固定 16 字节;数据格式带版本号;AAD 绑定协议上下文;解密异常必须向上抛出,不能吞掉;跨语言对接先跑测试向量。按这个清单做下来,至少能避开我踩过的大部分坑。最后再分享一个小技巧:调试阶段可以把 Nonce、Tag、密文全部打成 Base64 日志,对照着排查问题非常直观,但要记得上线前去掉敏感日志。AES-GCM 是个好工具,但也需要你尊重它的使用规则,用对了,它能帮你省下很多不必要的麻烦。