ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# AES加密解密实战:字符串与文件加密完整指南

C# AES加密解密实战:字符串与文件加密完整指南 作为一个常年和C#打交道的开发者我几乎每个项目都会遇到数据安全的需求。不管是上位机里存的配置文件、给客户做的小工具传的参数还是系统间对接的敏感字段裸奔的字符串和文件总让人心里不踏实。把AES加密解密这块吃透基本就是C#开发者的必修课。这篇博文我把自己在实际项目里反复用过、踩过坑之后沉淀下来的方案完整写出来覆盖字符串和文件两大场景。里面包含了核心原理讲解、可直接复制的完整代码、参数选型的权衡逻辑还有我在生产环境里遇到的各种坑和排查思路。不管你是刚接触加密的新手还是想把手里的工具类软件做得更严谨的老手这篇文章应该都能让你少走不少弯路。1. 设计思路拆解先搞清楚AES加密的本质1.1 为什么是AES而不是DES或RSA很多初学者一开始会纠结选什么加密算法。我先说结论在C#生态里做对称加密AES就是当前最合理的主流选择没有之一。AESAdvanced Encryption Standard是美国国家标准与技术研究院在2001年正式推出的加密标准它替代了老旧的DES和3DES。AES支持128位、192位、256位三种密钥长度密钥越长越难被暴力破解。256位密钥的暴力破解空间是2的256次方这个数量级比你想象的要恐怖得多——即使全世界的算力加起来算到宇宙热寂也解不开。那为什么不用RSA这是两类完全不同的算法。RSA是非对称加密用公钥加密、私钥解密适合密钥分发场景但性能比AES慢几个数量级。在C#里如果用RSA加密一个大文件速度慢到会让你怀疑人生。AES是对称加密加密解密用同一个密钥性能极高Intel和AMD的CPU甚至都在硬件层面集成了AES指令集加解密速度可以达到GB/s级别。所以实际项目里的常规做法是数据加解密用AESAES的密钥再用RSA或密钥协商算法去保护。也就是所谓的混合加密体系各取所长。1.2 字符串和文件加密在架构上的共通之处无论是加密字符串还是加密文件整体流程都逃不开这几个环节密钥派生用户输入的密码通过某种方式转换成固定长度的AES密钥128/192/256位IV生成与处理CBC等模式下需要初始化向量IVInitialization Vector保证同样的明文加密后得到不同的密文加密核心创建AES对象设置密钥、IV、加密模式、填充方式通过CryptoStream进行数据流转输出封装将IV和密文按约定的格式组合字符串通常用Base64编码方便传输文件则按自定义文件头格式写入我在设计这两个方案时刻意让它们的密钥派生逻辑完全一致这样就能共用同一套密码管理机制。字符串加密输出的是Base64文本适合放到数据库、配置文件或URL参数里文件加密输出的是带有自定义格式的二进制文件适合做配置文件保护、导出数据加密、备份文件加密等场景。1.3 方案选型背后的几个关键决策我把这套方案里的关键决策点以及做出这些决策的原因整理成了一张表方便你理解每个环节为什么这么设计决策点我的选择原因密钥长度256位安全性高性能损失可以忽略加密模式CBC需要IV保护安全性好CTR、GCM等模式实现复杂度更高填充方式PKCS7标准填充C#内置支持处理任意长度明文密钥派生Rfc2898DeriveBytesPBKDF2比直接对密码做SHA256更抗暴力破解IV处理随机生成随密文一起存储避免IV重复导致密文模式泄露字符串输出Base64兼容性最好不丢字符方便传输存储后文我会把每个选择背后的细节和坑都展开聊这里先记住这份决策表后面所有的代码都是围绕这套决策展开的。2. 字符串AES加密解密完整实现与逐行解析2.1 字符串加密的完整代码先贴出我项目里一直在用的字符串加密解密类。这个类我重构过很多次目前的版本兼顾了安全性、易用性和兼容性using System; using System.IO; using System.Security.Cryptography; using System.Text; public static class AesStringCipher { private const int KeySize 256; private const int IvSize 128; private const int SaltSize 16; private const int Iterations 10000; /// summary /// 加密字符串返回Base64格式密文 /// /summary public static string Encrypt(string plainText, string password) { if (string.IsNullOrEmpty(plainText)) throw new ArgumentException(明文不能为空, nameof(plainText)); if (string.IsNullOrEmpty(password)) throw new ArgumentException(密码不能为空, nameof(password)); // 1. 生成随机Salt byte[] salt new byte[SaltSize]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(salt); } // 2. 通过PBKDF2派生密钥 byte[] key DeriveKey(password, salt); // 3. 生成随机IV byte[] iv new byte[IvSize / 8]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(iv); } // 4. 执行AES加密 byte[] encrypted; using (var aes Aes.Create()) { aes.KeySize KeySize; aes.BlockSize IvSize; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.Key key; aes.IV iv; using (var encryptor aes.CreateEncryptor()) using (var ms new MemoryStream()) { using (var cs new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) using (var sw new StreamWriter(cs, Encoding.UTF8)) { sw.Write(plainText); } encrypted ms.ToArray(); } } // 5. 封装输出Salt IV 密文 byte[] result new byte[salt.Length iv.Length encrypted.Length]; Buffer.BlockCopy(salt, 0, result, 0, salt.Length); Buffer.BlockCopy(iv, 0, result, salt.Length, iv.Length); Buffer.BlockCopy(encrypted, 0, result, salt.Length iv.Length, encrypted.Length); return Convert.ToBase64String(result); } /// summary /// 解密字符串自动从密文中提取Salt和IV /// /summary public static string Decrypt(string cipherText, string password) { if (string.IsNullOrEmpty(cipherText)) throw new ArgumentException(密文不能为空, nameof(cipherText)); if (string.IsNullOrEmpty(password)) throw new ArgumentException(密码不能为空, nameof(password)); byte[] fullData Convert.FromBase64String(cipherText); // 1. 提取Salt byte[] salt new byte[SaltSize]; Buffer.BlockCopy(fullData, 0, salt, 0, salt.Length); // 2. 提取IV byte[] iv new byte[IvSize / 8]; Buffer.BlockCopy(fullData, salt.Length, iv, 0, iv.Length); // 3. 提取密文 byte[] encrypted new byte[fullData.Length - salt.Length - iv.Length]; Buffer.BlockCopy(fullData, salt.Length iv.Length, encrypted, 0, encrypted.Length); // 4. 用相同的Salt重新派生密钥 byte[] key DeriveKey(password, salt); // 5. 执行AES解密 using (var aes Aes.Create()) { aes.KeySize KeySize; aes.BlockSize IvSize; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.Key key; aes.IV iv; using (var decryptor aes.CreateDecryptor()) using (var ms new MemoryStream(encrypted)) using (var cs new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) using (var sr new StreamReader(cs, Encoding.UTF8)) { return sr.ReadToEnd(); } } } private static byte[] DeriveKey(string password, byte[] salt) { using (var deriveBytes new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256)) { return deriveBytes.GetBytes(KeySize / 8); } } }2.2 这段代码里藏着的关键细节先说盐Salt和IV为什么必须随机。盐的作用是保证即使两个用户用了同一个密码派生出来的密钥也不同IV的作用是保证即使同一份明文用同一个密钥加密两次得到的密文也不一样。这两个随机值都通过RandomNumberGenerator.Create()来生成这是.NET里加密安全的随机数生成器不是Random那个伪随机数生成器。PBKDF2的迭代次数我设的是10000。这个值在大多数场景下够用迭代次数越高暴力破解一个密码需要的算力就越大但是解密时等待的时间也会变长。如果觉得10000心里没底可以调到50000甚至100000注意用户感受就好。在加密流程里我用了嵌套的using语句来处理CryptoStream。这里的坑在于CryptoStream在Dispose的时候会把缓冲区里最后一部分数据写出去并完成填充。如果提前把CryptoStream关掉之前就去拿MemoryStream里的数据可能导致末尾数据丢失或者填充不完整。我习惯的做法是让CryptoStream的作用域在MemoryStream内部保持嵌套关系确保流被正确Flush完成后再取数组。2.3 为什么解密能拿到Salt和IV把Salt和IV拼到密文前面一起输出解密的时候按固定长度切出来这样用户只需要记住密码不需要额外保存盐和IV。这个设计在加密领域叫“自包含密文格式”好处是解密时不需要额外传参坏处是密文长度会比纯密文多出Salt和IV的长度也就是多32字节左右十六进制下面就是多64个字符。这个格式我在生产环境里用了很久配合数据库里存的都是Base64文本长度比较稳定。如果是历史老系统需要改格式建议封装的时候加个版本号字段方便将来平滑迁移。2.4 一个小工具的完整调用示例光看类不够直观我给这个类配一个控制台Demousing System; class Program { static void Main() { string password MySecret2024; string secret Hello, 这是需要加密的敏感内容123; Console.WriteLine(原始字符串: secret); string encrypted AesStringCipher.Encrypt(secret, password); Console.WriteLine(加密后: encrypted); string decrypted AesStringCipher.Decrypt(encrypted, password); Console.WriteLine(解密后: decrypted); Console.WriteLine(加解密成功: (secret decrypted)); } }运行结果大致是这样原始字符串: Hello, 这是需要加密的敏感内容123 加密后: QWtpT2...一长串Base64字符 解密后: Hello, 这是需要加密的敏感内容123 加解密成功: True注意每次运行加密的结果都不一样这是正常的因为盐和IV每次都是随机生成的。只要密钥派生逻辑一致每次解密都能正确还原。3. 文件AES加密解密流式处理的完整方案3.1 文件和字符串加密的核心差异字符串加密里整个流程固定在内存中完成。但文件不一样一个配置文件可能几MB一个备份文件可能几个GB全部加载进内存再加密内存占用会非常难看甚至直接在32位进程里撑爆。所以文件加解密必须用流式处理读一块、加密一块、写一块。.NET的CryptoStream天生就是为流式加密设计的。你把一个输入流喂给它它把加密后的数据推到输出流底层按块处理内存占用基本是恒定的跟文件大小无关。这也是为什么FileStreamCryptoStream这套组合在C#里处理文件加密几乎是标准解法。3.2 自定义文件格式设计开始写代码前先定义加密文件的存储格式。我用的格式是按字节排列的偏移长度内容04字节版本标识固定为144字节Salt长度8Salt长度Salt字节8Salt长度4字节IV长度12Salt长度IV长度IV字节16Salt长度IV长度剩余AES密文文件头信息写成一个固定模板好处是将来换算法版本的时候解密端可以根据版本标识做兼容处理。比如将来升级到AES-GCM或者更换迭代次数老文件依然能解。3.3 文件加密的完整代码using System; using System.IO; using System.Security.Cryptography; using System.Text; public static class AesFileCipher { private const string MagicHeader AESF; private const int KeySize 256; private const int IvSize 128; private const int SaltSize 16; private const int Iterations 10000; /// summary /// 加密文件输出到指定路径 /// /summary public static void EncryptFile(string inputFilePath, string outputFilePath, string password) { byte[] salt new byte[SaltSize]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(salt); } byte[] key DeriveKey(password, salt); byte[] iv new byte[IvSize / 8]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(iv); } using (var aes Aes.Create()) { aes.KeySize KeySize; aes.BlockSize IvSize; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.Key key; aes.IV iv; using (var encryptor aes.CreateEncryptor()) using (var fsIn new FileStream(inputFilePath, FileMode.Open, FileAccess.Read)) using (var fsOut new FileStream(outputFilePath, FileMode.Create, FileAccess.Write)) using (var bw new BinaryWriter(fsOut, Encoding.UTF8, leaveOpen: true)) { // 写文件头 bw.Write(Encoding.ASCII.GetBytes(MagicHeader)); bw.Write(VersionCode); bw.Write(salt.Length); bw.Write(salt); bw.Write(iv.Length); bw.Write(iv); // 流式加密数据 using (var cs new CryptoStream(fsOut, encryptor, CryptoStreamMode.Write)) { fsIn.CopyTo(cs); } } } } /// summary /// 解密文件输出到指定路径 /// /summary public static void DecryptFile(string inputFilePath, string outputFilePath, string password) { using (var fsIn new FileStream(inputFilePath, FileMode.Open, FileAccess.Read)) using (var br new BinaryReader(fsIn, Encoding.UTF8, leaveOpen: true)) { // 校验文件头 byte[] magic br.ReadBytes(4); if (Encoding.ASCII.GetString(magic) ! MagicHeader) throw new InvalidDataException(不是有效的AES加密文件); byte[] version br.ReadBytes(4); if (version[0] ! 1) throw new NotSupportedException(不支持的加密文件版本); int saltLen br.ReadInt32(); byte[] salt br.ReadBytes(saltLen); int ivLen br.ReadInt32(); byte[] iv br.ReadBytes(ivLen); byte[] key DeriveKey(password, salt); using (var aes Aes.Create()) { aes.KeySize KeySize; aes.BlockSize IvSize; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; aes.Key key; aes.IV iv; using (var decryptor aes.CreateDecryptor()) using (var fsOut new FileStream(outputFilePath, FileMode.Create, FileAccess.Write)) using (var cs new CryptoStream(fsOut, decryptor, CryptoStreamMode.Write)) { br.BaseStream.CopyTo(cs); } } } } private static byte[] DeriveKey(string password, byte[] salt) { using (var deriveBytes new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256)) { return deriveBytes.GetBytes(KeySize / 8); } } }上面代码里的VersionCode请自己定义成一个int常量比如1。注意在C#里BinaryWriter.Write(1)这个重载会写入4字节的int。3.4 大文件加密时的内存表现我用这段代码加密过一个大概2GB的虚拟机镜像文件内存占用在整个加密过程中稳定在20MB左右速度大概每秒200-300MB取决于磁盘和CPU。这个表现足以覆盖绝大多数桌面工具和上位机软件的需求。如果你的文件是超大文件且对性能有极致要求可以再做两件事一是用FileStream的bufferSize参数调大缓冲区比如设置成1MB二是考虑用Parallel.ForEach配合分块读取但这样会牺牲代码的简单性还会引入分块边界的处理问题非必要不建议。3.5 别忘了解密后的完整性校验CBC模式本身不提供认证机制。换句话说密文在传输或保存过程中如果被篡改了一部分解密的时候可能不会报错而是解出一堆乱码。更严重的情况是攻击者修改了密文的某个块可能导致解密后明文中的某一块被可控地改变这叫做“比特翻转攻击”在CBC模式下确实存在。所以如果文件内容对完整性有要求我的建议是加密前对原始文件计算SHA256哈希把哈希值附在文件头里解密后重新计算明文哈希对比是否一致。这样只要文件有任何改动哪怕一个bit校验都会失败。这个扩展逻辑不复杂在加密流程里多加一个哈希字段解密流程里比对一下就行了。后面在问题排查部分我会再展开说。4. 常用参数与模式选型的深度解析4.1 加密模式CBC、ECB与GCM的对比很多人在C#里写AES时会纠结用哪种模式。最常碰到的三个候选是CBC、ECB和GCM我直接说结论ECB模式是最不该选的模式。它的每个明文块都独立加密相同的明文块会产出相同的密文块这在图像、结构化数据等场景下会明显泄露明文模式。虽然实现简单但安全性太差除非是极端的兼容性需求否则我建议直接放弃ECB。CBC模式是.NET里最常用的默认模式。每个明文块先和前一个密文块进行异或再用密钥加密。第一个块跟IV异或所以CBC模式必须有一个随机IV。它的优点是安全性好缺点是密文被篡改时检测不出来而且加密过程无法并行解密可以并行。对于大多数工具类软件、配置文件加密、数据交换场景CBC完全够用。GCM模式是目前密码学上推荐度越来越高的模式。它同时提供机密性和完整性认证能检测密文是否被篡改。GCM在.NET Core 3.0和.NET 5里都有原生支持通过AesGcm类来使用。如果你的项目跑在较新的.NET版本上建议优先考虑GCM而不是CBC。模式是否需要IV能否检测篡改并行加密C#实现难度ECB否否可以最低CBC是否不可以低GCM是是可以中CTR是否可以中需要自己实现CFB/OFB是否有限中4.2 填充方式不匹配是解不开的AES是分组加密算法数据按128位16字节一块所以最后一块不满16字节的时候必须填充。PKCS7是最常见的填充方式它的逻辑是缺几个字节就补几个值为“缺的字节数”的字节。比如最后差5个字节就补5个0x05。用PKCS7时有一个典型坑如果解密端的填充方式设成了None或者ZeroPadding会在数据末尾出现多余的填充字节导致字符串后面多出几个怪字符。反过来如果加密端压根没填充、解密端却设置了PKCS7解密过程通常会直接抛出CryptographicException: Padding is invalid and cannot be removed。遇到填充异常时先不要怀疑算法写错了优先检查两端的Mode和Padding是否一致。这个错误我在帮同行排查时见过无数次绝大多数情况下都是模式不一致导致的。4.3 密钥派生为什么不要直接把密码当Key用AES的密钥要求是固定长度的随机字节一般用16字节128位、24字节192位、32字节256位。用户输入的密码五花八门长度未必符合要求直接拿UTF8字节去填坑要么太短要么太长。更关键的问题是人类可记忆的密码通常信息熵很低比如“123456”“admin888”这种直接作为AES密钥暴力破解的成本极低。所以标准做法是用KDF密钥派生函数把用户密码拉伸成指定长度的随机密钥。PBKDF2是目前最经典的KDFC#里的Rfc2898DeriveBytes就是它的官方实现。它的核心思路是对密码和盐做多次HMAC迭代把迭代后的哈希结果作为派生密钥。我在前面的代码里设置迭代次数10000就是PBKDF2的专业用法。4.4 .NET版本差异Aes vs RijndaelManaged老项目里常能看到RijndaelManaged这个类它是AES标准推出前Rijndael算法的.NET实现。AES标准只采用Rijndael算法中块大小固定为128位的版本而RijndaelManaged允许块大小设为192位或256位。如果你和我一样用Aes.Create()就不用操心这个差异。Aes抽象类在.NET里始终使用128位块大小密钥长度支持128/192/256位符合AES标准。建议新代码一律用Aes.Create()不要去碰RijndaelManaged。一个是标准、一个是历史遗留选择标准准没错。5. 常见问题与排查技巧实录5.1 解密出的字符串乱码这类问题的高发原因有两个。第一个是字符编码不一致加密的时候用Encoding.UTF8转字节解密的时候用Encoding.Default或者Encoding.ASCII去读中文百分百会乱码。解决方案是两端统一用UTF8。第二个原因更隐蔽填充不完整。如果加密时CryptoStream没有正确Dispose就拿了结果最后一块填充数据可能丢失解密时发现数据长度不是16的倍数直接抛异常或者解出一段缺了结尾内容的乱码。遇到这种情况检查代码里using块的范围确保对CryptoStream的写入在取用数据前已经完整结束。5.2 同样的明文和密码每次加密结果都不一样这是正常现象不是bug。因为每次加密都重新生成了随机的Salt和IV所以密文每次都会不同。这恰恰说明系统是安全的设计——攻击者无法通过对比两次密文来判断明文是否相同。如果你把Salt和IV固定住那同样的明文和密码加密出来的密文就会一模一样。但这是危险设计强烈不建议。5.3 解密报“Padding is invalid and cannot be removed”这个异常在CBC模式解密时非常常见但它的报错信息其实不带任何具体原因容易让人一头雾水。根据我的经验从这几个角度排查密码不正确派生出来的Key不对解密时数据错乱填充校验失败Salt/IV提取错位如果你自定义了密文格式检查读取的偏移量是否和写入的一致密文被截断或额外追加了数据文件损坏、网络传输丢包等模式或填充不一致两端的Mode和Padding设置必须完全一致特别注意这个异常也可能是恶意攻击者在试探你的解密程序。考虑给程序加上异常保护连续失败N次后拒绝继续解密防止被用来做padding oracle攻击。5.4 加密后的文件如何安全传输很多人把加密文件和密码放在一起发送这相当于把钥匙和锁头打包送给别人。正确姿势是加密文件通过任意渠道传输密码通过另一个渠道单独传递。如果对安全性要求高密码不要明文发微信或邮件用专门的密码管理器或者当面告知。提示永远不要把解密密码硬编码在代码或者配置文件里。这在逆向工程面前等于没有加密。该让用户输入的就得让用户输入该从环境变量取的就从环境变量取。5.5 大文件解密后文件损坏解密过程本身如果抛异常输出文件通常是不完整的这点比较好排查。但还有一种隐蔽情况解密过程没报错但输出的文件打不开。这种情况多半是文件头信息解析发生了偏移导致解密后的数据流起始位置或者长度不对。我自己的排查经验是先用十六进制工具打开加密文件确认文件头里的Salt、IV字段和预期长度是否一致再写一个小的校验工具单独用固定的Salt和IV去解密一小段数据定位是文件头的问题还是数据区的问题。5.6 多线程加解密时的注意事项CryptoStream不是线程安全的一个加密流只能被一个线程使用。如果要对多个文件做并行加解密每个文件创建独立的AES实例、独立的CryptoStream、独立的Salt和IV。Aes.Create()每次返回的新实例在.NET里是线程独立的。另外注意同一个Aes实例在.NET 6之前的版本里CreateEncryptor和CreateDecryptor在并发场景下可能会有状态干扰底层和Windows CNG的交互。稳妥起见多线程场景下每个线程各创建自己的Aes实例不要共享。6. 一个生产环境里的完整案例去年我给某自动化测试平台做了一批数据导出工具目标文件是几十MB的JSON报表需要加密后才能让客户下载。需求很简单客户在界面上输入一个授权码就是密码导出的文件加密客户拿到文件后用同样的授权码在查看工具里解密。这个场景就是典型的文件AES加密。我把上面的AesFileCipher集成进了一个WPF工具用户选择文件、输入密钥、点击加密一键输出.aesf后缀的加密文件。解密端做成了命令行小工具和GUI两版GUI版方便普通用户命令行版方便自动化脚本调用。实际运行中遇到过两个印象很深的问题。第一次发版后有个客户反馈解密后的文件打不开报“不是有效的JSON”。远程排查发现他的文件是在Windows上加密的密文拷到Linux服务器后又传回来传输过程中被某些中转环节当成文本流做了字符编码转换把二进制数据改坏了。后来我在解密端做了严格的文件头校验遇到格式不对的直接给出友好提示而不是继续硬解暴露一堆看不懂的异常。第二个问题是密钥管理。授权码是平台自动生成的格式是一串数字加字母。有些用户觉得太长想改成自己的简单密码。我必须确保即使用户换了个6位短密码整个系统也不会因为这个选择而出现严重漏洞。PBKDF2在这里起到了关键作用——就算密码熵比较低迭代和盐也能明显加大暴力破解的代价。这个案例给了我一个很深的体会加密库本身写对了只是第一步真正考验工程能力的是数据格式设计、异常处理、密钥管理和用户提示这些周边环节。密码学算法是标准化的但怎么把算法用对、用稳每个项目都有自己的门道。最后分享一个我在多次重构中沉淀下来的习惯无论字符串加密还是文件加密都给最终封装好的方法写一组单元测试覆盖正常加解密、错误密码、空数据、数据篡改、超大文件等场景。这套测试平时看起来不起眼但在你改动底层实现或者升级.NET版本时能帮你瞬间发现回归问题。加密这种东西一旦线上出了问题排查成本远高于写测试的成本提前把网织好后面才能睡得踏实。
返回列表