C# AES-GCM 加密详解:从原理到落地实践与踩坑指南
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 不一样它是一种带认证的加密模式AEADAuthenticated 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 也变得越来越好用。你不需要引用任何第三方库直接用 BCLBase 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剩下的就是密文。AADAssociated Data附加认证数据AAD 是 GCM 的一个特色功能它不参与加密但参与认证。也就是说AAD 会以明文形式传输或存储但 GCM 会把它纳入 Tag 的计算范围。只要 AAD 有一位被改动Tag 校验就会失败。AAD 一般用来绑定上下文信息比如协议版本号、消息类型、发送方 ID、时间戳、密钥 ID 等。举个例子你可以在加密时把protocol_v1作为 AAD解密时也传入同样内容这样攻击者如果把一份密文从协议 v1 环境搬到 v2 环境去重放解密就会直接失败。2.3 加解密数据格式设计怎么把参数存到一起实际开发中一个很常见的问题是Nonce、Tag、密文到底怎么放网络上很多示例代码是分开返回的但在真实项目里我们需要的是一个完整的、可以落盘或上送的自包含数据块。我推荐使用这样的二进制布局字段长度说明版本号1 字节用于标识格式版本方便以后升级算法Nonce 长度1 字节固定 12 通常但预留扩展Nonce12 字节随机生成的 NonceTag 长度1 字节通常是 16Tag16 字节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 流式 GCMBouncyCastle 的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 是可以正常执行的密文长度为 0Tag 依然生成。但有些库对空数组支持不好或者解密时空密文数组被当成 null 处理这里建议在解密入口统一判断一下if (data null || data.Length 0) return Array.Emptybyte();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 不是 byte128 表示 16 字节。写错就直接解密失败。我自己跨语言对接时习惯在接口文档里贴一段“最小可验证代码”比如 C# 加密出来一个固定的测试向量key 000102...1f, plaintext hello, nonce 000102...0b, aad empty对方先用这段向量验证自己的解密代码是否正确然后再联调。这一步能省掉大量排查时间。5. 常见问题速查与排查思路5.1 解密报错 CryptographicException 的几个原因下面这张表是我在实际支持同事时经常用来快速定位问题的现象可能原因排查/解决办法CryptographicException: The authentication tag is not validTag 被篡改、Nonce 不对、AAD 不一致、密钥不对确认四要素Key、Nonce、Tag、AAD 是否与加密时一致检查传输链路是否改过原始字节ArgumentException: Nonce byte count must be...Nonce 长度不是 12 字节或非标准长度检查 Nonce 是否被截断或版本兼容逻辑有 bugArgumentException: The tag length in bytes is not supportedTag 长度不是 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 是个好工具但也需要你尊重它的使用规则用对了它能帮你省下很多不必要的麻烦。