ARTICLE DETAIL

资讯详情

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

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

C# AES加密解密实战:从字符串到文件的完整指南 1. 写加密代码前先弄明白AES到底在保护什么前段时间有个做上位机开发的朋友问我客户要求把采集到本地的数据文件做加密处理防止别人直接拖走文件就能看到内容。他第一反应是搜文件加密代码结果搜出来的东西五花八门有的用DES有的自己写异或还有的干脆把文件转成Base64就说是加密了。这里必须说清楚一件事Base64不是加密它只是编码任何人拿到都能直接还原。真正的加密要满足一个基本要求——没有密钥的人即使拿到密文也还原不出原始内容。AES全称是Advanced Encryption Standard高级加密标准是NIST在2001年正式批准的分组对称加密算法用来取代早就不安全的DES和3DES。它从被确立到现在二十多年经历了大量的密码分析攻击考验至今没有找到比暴力穷举更有效的攻击方式所以在实际工程里它就是对称加密的事实标准。我们常见的WiFi加密WPA2/WPA3、磁盘加密BitLocker、HTTPS里的对称加密部分背后都有AES的身影。对称加密的意思是加密和解密使用同一把密钥。对应的另一类是非对称加密比如RSA、ECC公钥加密、私钥解密两把钥匙。很多初学者在这两者之间纠结其实选型逻辑很清晰如果你要保护的是自己系统内部存储的数据、文件加解密的双方都是你自己控制的程序那对称加密AES就是首选速度快、实现简单、密文长度可控。只有当你需要把数据安全地传送给一个事先没有共享密钥的第三方时才需要考虑非对称加密或者用非对称加密来协商出对称密钥这就是HTTPS的TLS握手干的事。在C#里实现AES加密解密本质上就是围绕System.Security.Cryptography命名空间下的Aes类来做文章。.NET Framework时代它有RijndaelManaged.NET Core/.NET 5之后推荐用Aes.Create()后者在不同平台上会自动选择合适的底层实现你的代码不需要关心到底是OpenSSL还是Windows CSP在干活这种抽象对开发者来说非常省心。说句题外话这几年看到很多文章还在教人用RijndaelManaged写新代码这属于历史包袱新的项目一律用Aes.Create()就好API更干净跨平台行为也更一致。2. 字符串加密一个工具类搞定加密和解密字符串加密是很多AES需求的起点比如加密数据库连接字符串、加密接口返回的敏感字段、加密配置文件里的密钥。它的核心思路不复杂把字符串转成字节数组经过AES加密后得到密文字节数组再转成可打印的字符串输出。解密就是反过来的过程。2.1 动手之前先定好四个参数写代码之前有几件事必须提前决定因为加密和解密必须使用完全相同的参数才能互相还原。这四个参数是密钥长度128位16字节、192位24字节或256位32字节。建议直接用256位安全性最高性能损耗对绝大多数应用来说可以忽略。工作模式常用的是CBC和GCM。CBC需要配合填充和IV使用GCM是AEAD模式自带认证后面专门讲。初学先用CBC。填充方式AES是分组加密每次加密16字节一块最后一块不足16字节时必须填充。PKCS7是.NET里的默认选项也是行业通用做法。编码方式字符串转字节数组编码统一用UTF-8避免不同平台默认编码不一致导致乱码。2.2 一个完整可用的字符串加解密工具类话不多说上代码。这是我项目里实际在用的一个简版工具类把AES的常用逻辑都封装好了using System; using System.IO; using System.Security.Cryptography; using System.Text; public static class AesStringCipher { // 密钥和IV都由调用方提供这里不做硬编码 public static string Encrypt(string plainText, byte[] key, byte[] iv) { if (string.IsNullOrEmpty(plainText)) return string.Empty; byte[] plainBytes Encoding.UTF8.GetBytes(plainText); using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var ms new MemoryStream()) using (var cs new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) { cs.Write(plainBytes, 0, plainBytes.Length); cs.FlushFinalBlock(); return Convert.ToBase64String(ms.ToArray()); } } } public static string Decrypt(string cipherText, byte[] key, byte[] iv) { if (string.IsNullOrEmpty(cipherText)) return string.Empty; byte[] cipherBytes Convert.FromBase64String(cipherText); using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var ms new MemoryStream(cipherBytes)) using (var cs new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read)) using (var sr new StreamReader(cs, Encoding.UTF8)) { return sr.ReadToEnd(); } } } }调用方式很简单byte[] key Convert.FromBase64String(base64编码的32字节密钥); byte[] iv Convert.FromBase64String(base64编码的16字节IV); string cipher AesStringCipher.Encrypt(敏感数据123, key, iv); string plain AesStringCipher.Decrypt(cipher, key, iv);这段代码有几个细节值得单独拎出来说。第一FlushFinalBlock()必须调用。有人习惯把CryptoStream用在using块里然后调DisposeDispose内部确实会调用FlushFinalBlock但如果我像上面这样还需要在Dispose之前拿到MemoryStream的字节数组就必须显式调用FlushFinalBlock()。否则ms.ToArray()拿到的是不完整的数据最后一块填充没有写入解密时就会报填充无效无法移除的错误。第二为什么用StreamReader.ReadToEnd()而不是循环读字节。解密方向我用StreamReader直接按UTF-8读取是因为密文解密后本身就是合法的UTF-8字符串直接读省事。对称性上加密方向用cs.Write写入原始字节解密方向用StreamReader读取逻辑是清晰且对称的。第三密钥和IV的字节长度必须严格匹配。AES-256要求32字节密钥AES-128要求16字节密钥IV无论哪种密钥长度都是16字节因为分组大小固定128位。长度不对Aes.Create()在给Key或IV赋值时会直接抛CryptographicException所以如果你的代码报这个错第一反应就是检查字节长度。2.3 输出格式用Base64还是Hex密文是字节数组不能直接打印或者存数据库必须编码成可打印字符串。两种选择Base64和Hex十六进制。我推荐Base64理由很实际密文长度短。Base64编码每3个字节变成4个字符Hex每1个字节变成2个字符。一份AES-256-CBC加密后的数据Base64比Hex大概短四分之一。字段存数据库时VARCHAR的长度可以开得更小JSON传输时数据量也更少实测感觉差异明显。Hex的好处是可读性强一点肉眼排查问题时方便对照但它体积大一截。我的习惯是程序内部统一用Base64只有在调试、人工对照密文场景下才临时转Hex看。传输和存储时还有一个细节Base64字符串里可能包含和/在URL里直接拼接会冲突。如果你的密文要放进URL参数记得用UrlEncoder处理或者干脆把替换成-、/替换成_这是Base64URL变体的做法很多JWT实现就是这么处理的。3. 文件加密流式读写才不会被大文件压垮字符串加密搞定了很多人自然而然想把文件读成字节数组然后走同样的加密流程不就行了对几KB的小文件确实没问题但一旦文件到了几十MB、几百MB一次性File.ReadAllBytes()会把内存直接吃满程序卡死甚至OOM这在生产环境里是灾难。正确的做法是全程用流一边从源文件读数据一边加密一边写入目标文件。CryptoStream的设计天生就支持这种管道式处理完全不需要把整个文件load到内存里。3.1 文件加密的完整实现public static void EncryptFile(string inputFile, string outputFile, byte[] key, byte[] iv) { using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var fsIn new FileStream(inputFile, FileMode.Open, FileAccess.Read)) using (var fsOut new FileStream(outputFile, FileMode.Create, FileAccess.Write)) using (var cs new CryptoStream(fsOut, aes.CreateEncryptor(), CryptoStreamMode.Write)) { fsIn.CopyTo(cs); } } } public static void DecryptFile(string inputFile, string outputFile, byte[] key, byte[] iv) { using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var fsIn new FileStream(inputFile, FileMode.Open, FileAccess.Read)) using (var fsOut new FileStream(outputFile, FileMode.Create, FileAccess.Write)) using (var cs new CryptoStream(fsIn, aes.CreateDecryptor(), CryptoStreamMode.Read)) { cs.CopyTo(fsOut); } } }核心就一个fsIn.CopyTo(cs)。这一步内部会按缓冲区大小默认80KB分块读取源文件每读满一块就送入CryptoStream完成加密再写入输出流。整个过程中内存里始终只占一个缓冲区的数据不管文件是1MB还是1GB内存占用都是恒定的。这里我要强调一个容易被忽略的点加密方向CryptoStream包着输出流解密方向CryptoStream包着输入流。为什么不对称从语义上想就明白了加密时我们要把明文读取 加密 写入这个过程串起来所以CryptoStream是写模式包在输出流外面写入的数据先经过加密再落盘解密时我们要读密文 解密 输出明文所以CryptoStream是读模式包在输入流外面读出来的数据经过解密后再传给目标流。搞反了会出现奇怪的问题最常见的就是解密后文件头几个字节乱码。3.2 IV的存放策略单独传还是写进文件头上面的代码里密钥和IV都是参数由调用方管理。实际项目中IV应该怎么存有的人图省事写死一个固定IV这是典型的反面教材——同一把密钥和同一个IV加密多份文件攻击者会从密文中发现大量信息泄露。AES-CBC模式下IV相同且密钥相同相同明文前缀会产生相同密文前缀这是严重的安全缺陷。推荐的方案是每次加密随机生成一个IV把IV写入加密文件头。解密时先从文件头读出IV再解密剩余数据。这样一来IV不再是调用方需要维护的参数它跟着文件走而IV本身不是机密公开不影响安全性。public static void EncryptFileWithRandomIv(string inputFile, string outputFile, byte[] key) { using (var aes Aes.Create()) { aes.Key key; aes.GenerateIV(); // 每次生成随机IV using (var fsIn new FileStream(inputFile, FileMode.Open, FileAccess.Read)) using (var fsOut new FileStream(outputFile, FileMode.Create, FileAccess.Write)) { // 先写IV到文件头 fsOut.Write(aes.IV, 0, aes.IV.Length); using (var cs new CryptoStream(fsOut, aes.CreateEncryptor(), CryptoStreamMode.Write)) { fsIn.CopyTo(cs); cs.FlushFinalBlock(); } } } } public static void DecryptFileWithReadIv(string inputFile, string outputFile, byte[] key) { using (var aes Aes.Create()) { aes.Key key; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var fsIn new FileStream(inputFile, FileMode.Open, FileAccess.Read)) using (var fsOut new FileStream(outputFile, FileMode.Create, FileAccess.Write)) { // 先读IV byte[] iv new byte[16]; fsIn.Read(iv, 0, iv.Length); aes.IV iv; using (var cs new CryptoStream(fsIn, aes.CreateDecryptor(), CryptoStreamMode.Read)) { cs.CopyTo(fsOut); } } } }这个IV跟密文一起存的做法是我做文件加密时最推荐的结构。文件格式上前16个字节是IV后面是密文。自己定义格式时还可以在IV前面再加一个魔数头比如固定的几个字节AES1用来标识文件类型和版本这为将来算法升级留了余地——读到老版本魔数走老逻辑新版本走新逻辑不用靠文件名后缀硬猜。这里还要注意一点代码里传IV的参数版本不是没用它在你需要自己控制IV来源的场景比如从外部配置读IV或者和别的系统对接时协议约定好了IV规则还是有价值的。我的做法是两种都保留项目里按需调用。3.3 大文件处理还有什么细节用CopyTo处理大文件内存没问题了但还有两个工程细节值得注意第一缓冲区大小默认80KB在多数情况下够用。但如果你处理的是海量小文件比如上万个几KB的文件每个文件都走80KB缓冲区反而不划算可以把缓冲区调小到4KB左右。反过来处理超大的单个文件并且追求吞吐量可以自己创建缓冲区传入CopyTobyte[] buffer new byte[1024 * 1024]; // 1MB fsIn.CopyTo(cs, buffer.Length);第二加密过程是CPU密集操作大文件加密需要持续占用CPU。如果你的程序是UI程序直接在主线程跑几百MB文件的加密界面必然卡死。这时候用async/await配合FileStream的异步API会好很多。CryptoStream从.NET Core 3.0开始也支持异步读写了用法是await fsIn.CopyToAsync(cs)底层会用线程池调度UI线程不会阻塞。这在写桌面工具和上位机软件时尤其重要用户点一下加密结果界面假死了10秒钟体验非常糟糕。4. 实战中高频踩坑填充、IV、编码这三个老大难写AES加密方案从能跑通到稳定可靠中间隔着无数个坑。我把自己和身边同事踩过的、以及在网上经常被问到的问题集中讲一下每一个都是真实场景不是教科书上的理论。4.1 填充异常解密时报Padding is invalid怎么办这是AES初学者遇到最多的报错。填充无效无法移除CryptographicException: Padding is invalid and cannot be removed八成原因都是密钥或IV不对不是填充本身的问题。CBC模式下解密时最后一组数据要按PKCS7规则去掉填充字节。如果密钥或IV和加密时不一致解密出来的最后一块几乎是随机字节大概率不符合PKCS7填充规则于是框架就抛这个异常。所以排查顺序应该是先确认密钥字节数组是否完全一致逐字节比对别肉眼比写个循环或打印Hex看。确认IV是否一致尤其是我上面说的每次随机生成IV方案里解密时IV是否真的从文件头读对了。确认密钥长度参数一致一方用AES-128一方用AES-256长度都对不上直接赋值就报错了。有一个特殊的坑值得单独提如果你用Rfc2898DeriveBytes从密码派生密钥加密和解密时的迭代次数、Salt必须完全一致错一个数字密钥就完全不同。这个我后面详细讲这里先记住结论。还有一种情况是密文在传输或存储过程中被截断、损坏了CBC模式下任何一位数据损坏都会导致该块解密失败且会连带影响下一块。如果你的业务场景对数据完整性有要求光靠CBC不够需要用带认证的GCM模式或者额外加HMAC校验。4.2 IV使用不当带来的两类典型问题IV是我见过最容易被误用、又代价最大的参数。典型错误有两种。第一种是所有文件共用同一个IV。前面说了相同密钥和相同IV加密相同明文会产生相同密文攻击者可以从密文相等性反推明文关系。而且CBC模式的IV如果固定等于削弱了整个加密体系的安全性。网上大量教程为了简化代码把IV写死成一个常量这种代码教学性质可以理解但放进生产环境就是隐患。正确做法永远是随机生成。第二种是IV和密钥混淆。有的朋友在调用接口时随手把密钥的前16字节截出来当IV用这在技术上可行但安全性很差——密钥和IV存在可推导关系攻击者拿到IV就能缩小密钥的搜索空间。IV应该是独立的随机数而不是密钥的某种推导结果。另外解密时IV要从密文来源同步获取不要依赖全局只有一个IV这样的假设。多线程场景下尤其危险如果用一个静态IV字段线程A更新了IV线程B还在用旧IV解密数据全部解密失败。这属于典型的共享可变状态并发bug而且特别难排查。4.3 编码不一致导致的乱码和异常编码问题在字符串加密里非常常见。举一个我见过很多次的场景一套系统里C#端用UTF-8编码明文Java端用平台默认编码Windows中文环境是GBK解码两边接口对不上中文全部乱码有些特殊字符甚至解密直接失败。解决方案只有一个标准答案字符串和字节数组互转统一用UTF-8不止是加密和解密两端整条链路上的所有应用都必须统一。UTF-8是跨语言、跨平台兼容性最好的编码没有之一。不是什么场景都非要UTF-8但加密场景下大家语言环境复杂的概率很高UTF-8能最大程度减少意外。还有一个小坑是Encoding.Default在不同机器上结果可能不同尤其是Windows非中文版本上。所以代码里永远不要用Encoding.Default全部显式指定Encoding.UTF8让行为可预期。4.4 把密钥硬编码在代码里的后果最后说一个工程层面的坑密钥硬编码。很多Demo代码直接写byte[] key new byte[] { 0x01, 0x02, ... };看着方便上线后就成了定时炸弹。密钥一旦泄露出现在Git历史里所有用它加密的数据都等于裸奔。实际项目中密钥至少要做到放在配置文件或环境变量里不进代码库。使用SecureString或字节数组不用普通字符串存密钥避免字符串驻留内存无法擦除。密钥定期轮换轮换策略依赖你存储密文的系统支持比如存数据时带一个密钥版本号字段。如果是对安全性要求极高的系统使用硬件安全模块HSM或云KMS服务管理密钥托管平台负责密钥的保护和轮换程序只拿临时解密的密钥句柄操作。哪怕只是做到前两条你系统的安全水位已经比绝大多数中小型项目高出一截了。5. 从能用进阶到好用密钥派生与模式选型基础的加密解密跑通只是第一步一个真正能上线、经得起考验的方案还要解决两件事怎么安全地管理密钥以及怎么确保证密文不被篡改。这一节讲两个进阶方向都是我在实际项目中逐步摸索出来的实践。5.1 用PBKDF2从用户口令派生密钥而不是直接拿口令当密钥很多业务系统里用户只有一个密码口令但你不可能让用户提供一个32字节的随机密钥。直接拿用户口令的字节当作AES密钥是错误做法原因是口令的熵通常很低字典攻击可以快速爆破。行业标准做法是用密钥派生函数把口令和随机盐Salt做迭代计算生成高熵的密钥。.NET里对应的实现是Rfc2898DeriveBytes实现了PBKDF2标准public static byte[] DeriveKey(string password, byte[] salt, int keyBytes 32, int iterations 100000) { using (var deriveBytes new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256)) { return deriveBytes.GetBytes(keyBytes); } }Salt是随机生成的16字节数组它的作用是为防彩虹表攻击——同样的口令加上不同Salt派生出的密钥完全不同。Salt不需要保密和IV一样可以随密文一起存储。这里有一个最重要的经验教训派生密钥的所有参数口令、Salt、迭代次数、Hash算法必须全部一致解密才能成功。我见过不止一次同事把迭代次数从10000调成100000以增强安全性结果只改了加密方向的代码解密方向还是老迭代次数整个项目的数据全解不开了。这种错误极难排查因为它只在你调用解密时才暴露而且报错信息往往只是笼统的填充无效。所以在设计时我会把Salt、迭代次数、算法版本这些参数都封装进同一个数据格式里随密文一起序列化保存。解密时先读出这些参数再派生密钥。这样即使将来算法升级老数据也能按老参数解密不会出现全线崩溃的情况。5.2 CBC还是GCM认证加密带来的体验差异CBC模式虽然经典但它有一个致命短板不提供数据完整性校验。密文在传输或存储中被篡改哪怕是翻转一个bit解密时不一定报错只是解出错误明文而在某些场景下这可能被攻击者利用来操纵数据内容。GCMGalois/Counter Mode是AEAD认证加密模式它在加密的同时计算一个认证标签Authentication Tag解密时先校验标签确认密文没有被篡改才解出明文。一旦密文有任何改动解密直接失败的反馈非常明确。.NET Core 3.0之后完全支持AES-GCM用法如下public static byte[] AesGcmEncrypt(byte[] plainBytes, byte[] key, byte[] nonce, byte[] tag) { var cipherBytes new byte[plainBytes.Length]; using (var aesGcm new AesGcm(key, 16)) // 16字节tag { aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); } return cipherBytes; } public static byte[] AesGcmDecrypt(byte[] cipherBytes, byte[] key, byte[] nonce, byte[] tag) { var plainBytes new byte[cipherBytes.Length]; using (var aesGcm new AesGcm(key, 16)) { aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); } return plainBytes; }GCM的Nonce类似IV是12字节为标准推荐值同样要求每次加密随机生成、唯一使用。解密时如果标签校验失败会抛AuthenticationTagMismatchException这个异常含义非常明确数据被篡改或密钥不对可以直接在前端提示用户文件损坏。我在实战中的选型原则是如果是外部传入的文件或数据优先GCM防止被中间人篡改如果数据完全在自己系统内部流转且你有独立的完整性校验机制比如数据库字段本身有校验CBC也够用。但从长期维护的角度我越来越倾向全部用GCM因为省掉一层单独做HMAC的功夫而且API设计上强制你把Nonce和Tag都管理好不太容易踩坑。另一个实践细节是GCM加密是流式处理整块数据的不适合你手写分块循环直接用字节数组处理好长度可控的数据就行。对于超大文件超过几百MB还不适合一次性载入内存的就需要逐块用GCM的增量接口或者改用CBC加HMAC方案这个属于高阶场景本文先不展开。5.3 我在实际项目里的最佳实践清单最后把我在多个项目里沉淀下来的一套做法整理成清单你可以直接照着做密钥长度统一用256位32字节工作模式优先GCM其次CBC随机IV。字符串加密的输出格式统一用Base64URL传输用Base64URL变体。文件加密时自定义文件头前几个字节是版本标记接着12/16字节Nonce后面是密文和认证标签。解密逻辑先读头再操作主体。用户口令不直接当密钥用PBKDF2迭代至少10万次SHA-256派生Salt随机生成并随密文保存。加解密代码全部使用using确保释放非托管资源CryptoStream及时Dispose避免句柄泄漏。密钥永不出现在代码里和Git历史上从环境变量或配置中心读取。多线程使用加解密工具类时确认内部无共享可变状态。Aes.Create()每次新建实例千万不要设计成单例复用同一个Aes对象。日志里永远不要打明文密钥、明文密码更不要打完整密文顶多打前几位用于关联定位。这些清单里的每一条几乎都能对应到一个真实的事故案例。我在项目维护中最大的体会就是加密功能实现本身很简单难的是从长期安全的角度把每个细节做对。与其等出了问题再救火不如一开始就把这些规范固化到代码评审清单里。回到最开头那个做上位机的朋友他后来按这套思路把本地的数据文件处理做完了用GCM模式文件头存Nonce和Tag密钥配置在部署环境的环境变量里测试了几百MB的文件内存占用稳定。我把他的实践细节整理成了这篇文章希望你看完不只是会贴两段加密代码而是真正理解每个参数背后的为什么在项目里能做出安全、可维护的方案。
返回列表