.NET中使用AesGcm加密时是否需要设置迭代次数?
我了解到RijndaelManaged已被弃用,.NET Core 3.1引入的AesGcm相比AesManaged是更推荐的实现,因此我参考相关教程与Stack Overflow解答尝试实现AesGcm加解密,代码如下:
以下是我的实现代码:
/// Perform AES Encryption, returning the result as a byte array. /// </summary> /// <param name="bytesToEncrypt">string, file or data represented as byte array</param> /// <param name="passwordBytes">A unique password for the encryption (must be 32 bytes?)</param> /// <returns>The data encrypted</returns> public byte[] EncryptData(byte[] bytesToEncrypt, byte[] passwordBytes) { // 参考公开实现逻辑 // Get parameter sizes int nonceSize = AesGcm.NonceByteSizes.MaxSize; int tagSize = AesGcm.TagByteSizes.MaxSize; int cipherSize = bytesToEncrypt.Length; // We write everything into one big array for easier encoding int encryptedDataLength = 4 + nonceSize + 4 + tagSize + cipherSize; Span<byte> encryptedData = encryptedDataLength < 1024 ? stackalloc byte[encryptedDataLength] : new byte[encryptedDataLength].AsSpan(); // Copy parameters BinaryPrimitives.WriteInt32LittleEndian(encryptedData.Slice(0, 4), nonceSize); BinaryPrimitives.WriteInt32LittleEndian(encryptedData.Slice(4 + nonceSize, 4), tagSize); var nonce = encryptedData.Slice(4, nonceSize); var tag = encryptedData.Slice(4 + nonceSize + 4, tagSize); var cipherBytes = encryptedData.Slice(4 + nonceSize + 4 + tagSize, cipherSize); // Generate secure nonce RandomNumberGenerator.Fill(nonce); // Encrypt using (var aes = new AesGcm(passwordBytes)) { aes.Encrypt(nonce, bytesToEncrypt.AsSpan(), cipherBytes, tag); } return encryptedData.ToArray(); } /// <summary> /// Takes in an AES encrypted byte array, decrypts it and returns the resulting unencrypted byte array. /// </summary> /// <param name="encryptedBytes">A string, file or object represented as a byte array that's previously been encrypted.</param> /// <param name="passwordBytes">The password used to encrypt the data. </param> /// <returns></returns> public byte[] DecryptData(byte[] encryptedBytes, byte[] passwordBytes) { // Decode Span<byte> encryptedData = encryptedBytes.AsSpan(); // Extract parameter sizes int nonceSize = BinaryPrimitives.ReadInt32LittleEndian(encryptedData.Slice(0, 4)); int tagSize = BinaryPrimitives.ReadInt32LittleEndian(encryptedData.Slice(4 + nonceSize, 4)); int cipherSize = encryptedData.Length - 4 - nonceSize - 4 - tagSize; // Extract parameters var nonce = encryptedData.Slice(4, nonceSize); var tag = encryptedData.Slice(4 + nonceSize + 4, tagSize); var cipherBytes = encryptedData.Slice(4 + nonceSize + 4 + tagSize, cipherSize); // Decrypt Span<byte> plainBytes = cipherSize < 1024 ? stackalloc byte[cipherSize] : new byte[cipherSize]; using (var aes = new AesGcm(passwordBytes)) { aes.Decrypt(nonce, cipherBytes, tag, plainBytes); } // Convert plain bytes back into string return plainBytes.ToArray(); }
我注意到当前实现中没有设置迭代次数的位置。
例如我之前使用AesManaged时,一直会按照如下方式设置迭代,迭代可以提升攻击难度,我在实现密码哈希时也采用了类似模式:
// Set Symmetric encryption algorithm var AES = Aes.Create("AesManaged"); AES.KeySize = 256; AES.BlockSize = 128; AES.Padding = PaddingMode.PKCS7; // 多轮哈希拉伸密钥,提升暴力破解成本 var key = new Rfc2898DeriveBytes(passwordBytes, salt, 100000); AES.Key = key.GetBytes(AES.KeySize / 8); AES.IV = key.GetBytes(AES.BlockSize / 8); ...
我知道nonce会影响加密安全性,但我之前的所有实现都依赖多轮迭代,AesGcm中不设置迭代显得很反常。
AesGcm似乎没有内置迭代配置入口,请问使用AesGcm时是否需要在流程中加入迭代?如果需要的话应当如何正确实现?
首先要明确核心逻辑:你之前用的迭代根本不是AES算法本身的配置,是密钥派生阶段的操作,和你用AesManaged还是AesGcm没有任何关系。
AES(不管是CBC、GCM哪种工作模式)本身的输入要求是固定长度的高熵密钥,算法自身没有迭代的概念。你之前写的100000次迭代,是Rfc2898DeriveBytes也就是PBKDF2密钥派生函数的参数,作用是把用户输入的低熵、长度不固定的密码,通过多轮哈希拉伸成符合AES要求的定长高熵密钥,增加暴力破解密码的成本。
你现在写的AesGcm代码相当于直接把传入的passwordBytes当成了AES密钥使用,本身存在两个明显问题:
- 如果传入的是用户自行设置的短密码、低熵密码,没有经过密钥派生,暴力破解成本极低,完全达不到安全要求
- AesGcm要求密钥必须是16/24/32字节(对应128/192/256位AES),如果传入的字节长度不符合要求,运行时会直接抛出异常
所以当然要加迭代,但迭代不是加在AesGcm的加解密逻辑里,是加在把用户密码转换成AES密钥的密钥派生步骤里,和你之前用AesManaged时的逻辑位置完全一致。
正确的实现流程如下:
- 加密时,先生成一个随机盐(至少16字节,用加密安全的随机数生成器生成)
- 用PBKDF2(.NET 8+更推荐用抗破解能力更强的Argon2id)搭配高迭代次数/高计算成本,把用户输入的密码+盐派生出32字节(256位)的AES密钥
- 注意:AesGcm的nonce是每次加密单独随机生成的,不要从密钥派生函数里取nonce。nonce不需要保密,只要保证同一密钥下不重复即可,可以和盐、密文、认证标签拼接在一起存储
- 用派生出来的合法密钥初始化AesGcm,完成加密
- 把盐、nonce长度、tag长度、nonce、tag、密文按固定格式拼接成最终的加密结果返回
- 解密时,先从加密结果里按固定偏移拆出盐、nonce、tag、密文,用和加密时完全一致的密钥派生参数(相同迭代次数、相同哈希算法),搭配用户输入的密码+拆出的盐派生出一致的密钥,再用这个密钥初始化AesGcm完成解密
核心加密逻辑的参考实现如下,可以对照调整你的代码:
// 2024年PBKDF2-SHA256建议迭代次数不低于31万,可以根据服务器性能适当调高 private const int KeyDerivationIterations = 310000; private const int SaltSize = 16; private const int KeySize = 32; // 对应256位AES密钥 public byte[] EncryptData(byte[] bytesToEncrypt, byte[] userPassword) { int nonceSize = AesGcm.NonceByteSizes.MaxSize; int tagSize = AesGcm.TagByteSizes.MaxSize; int cipherSize = bytesToEncrypt.Length; // 最终结果结构:盐(16字节) + nonce长度(4字节) + tag长度(4字节) + nonce + tag + 密文 int encryptedDataLength = SaltSize + 4 + nonceSize + 4 + tagSize + cipherSize; Span<byte> encryptedData = encryptedDataLength < 1024 ? stackalloc byte[encryptedDataLength] : new byte[encryptedDataLength].AsSpan(); // 生成随机盐 var salt = encryptedData.Slice(0, SaltSize); RandomNumberGenerator.Fill(salt); // 从用户密码派生符合要求的AES密钥 byte[] aesKey = Rfc2898DeriveBytes.Pbkdf2( userPassword, salt, KeyDerivationIterations, HashAlgorithmName.SHA256, KeySize); // 写入参数、生成随机nonce、完成加密,注意偏移量要加上盐的长度 int offset = SaltSize; BinaryPrimitives.WriteInt32LittleEndian(encryptedData.Slice(offset, 4), nonceSize); offset +=4; var nonce = encryptedData.Slice(offset, nonceSize); RandomNumberGenerator.Fill(nonce); offset += nonceSize; BinaryPrimitives.WriteInt32LittleEndian(encryptedData.Slice(offset,4), tagSize); offset +=4; var tag = encryptedData.Slice(offset, tagSize); offset += tagSize; var cipherBytes = encryptedData.Slice(offset, cipherSize); using var aes = new AesGcm(aesKey); aes.Encrypt(nonce, bytesToEncrypt.AsSpan(), cipherBytes, tag); // 清空内存中存储的密钥,避免敏感数据泄露 CryptographicOperations.ZeroMemory(aesKey); return encryptedData.ToArray(); }
解密逻辑对应调整偏移量、增加密钥派生步骤即可,解密完成后也要记得清空内存中的密钥。
额外提两个安全注意点:
- 不要复用nonce,AesGcm对同一密钥下的nonce唯一性要求极高,只要出现一次nonce重复,就会直接泄露密钥,每次加密必须生成全新的随机nonce
- PBKDF2属于比较老的密钥派生算法,抗GPU/ASIC暴力破解的能力一般,如果项目使用.NET 8及以上版本,建议替换为Argon2id实现,安全强度更高。
内容的提问来源于stack exchange,提问作者JsAndDotNet

