作为一个常年和C#打交道的开发者,我几乎每个项目都会遇到数据安全的需求。不管是上位机里存的配置文件、给客户做的小工具传的参数,还是系统间对接的敏感字段,裸奔的字符串和文件总让人心里不踏实。把AES加密解密这块吃透,基本就是C#开发者的必修课。
这篇博文我把自己在实际项目里反复用过、踩过坑之后沉淀下来的方案完整写出来,覆盖字符串和文件两大场景。里面包含了核心原理讲解、可直接复制的完整代码、参数选型的权衡逻辑,还有我在生产环境里遇到的各种坑和排查思路。不管你是刚接触加密的新手,还是想把手里的工具类软件做得更严谨的老手,这篇文章应该都能让你少走不少弯路。
1. 设计思路拆解:先搞清楚AES加密的本质
1.1 为什么是AES,而不是DES或RSA
很多初学者一开始会纠结选什么加密算法。我先说结论:在C#生态里做对称加密,AES就是当前最合理的主流选择,没有之一。
AES(Advanced Encryption Standard)是美国国家标准与技术研究院在2001年正式推出的加密标准,它替代了老旧的DES和3DES。AES支持128位、192位、256位三种密钥长度,密钥越长越难被暴力破解。256位密钥的暴力破解空间是2的256次方,这个数量级比你想象的要恐怖得多——即使全世界的算力加起来算到宇宙热寂也解不开。
那为什么不用RSA?这是两类完全不同的算法。RSA是非对称加密,用公钥加密、私钥解密,适合密钥分发场景,但性能比AES慢几个数量级。在C#里如果用RSA加密一个大文件,速度慢到会让你怀疑人生。AES是对称加密,加密解密用同一个密钥,性能极高,Intel和AMD的CPU甚至都在硬件层面集成了AES指令集,加解密速度可以达到GB/s级别。
所以实际项目里的常规做法是:数据加解密用AES,AES的密钥再用RSA或密钥协商算法去保护。也就是所谓的混合加密体系,各取所长。
1.2 字符串和文件加密在架构上的共通之处
无论是加密字符串还是加密文件,整体流程都逃不开这几个环节:
- 密钥派生:用户输入的密码,通过某种方式转换成固定长度的AES密钥(128/192/256位)
- IV生成与处理:CBC等模式下需要初始化向量IV(Initialization Vector),保证同样的明文加密后得到不同的密文
- 加密核心:创建AES对象,设置密钥、IV、加密模式、填充方式,通过CryptoStream进行数据流转
- 输出封装:将IV和密文按约定的格式组合,字符串通常用Base64编码方便传输,文件则按自定义文件头格式写入
我在设计这两个方案时,刻意让它们的密钥派生逻辑完全一致,这样就能共用同一套密码管理机制。字符串加密输出的是Base64文本,适合放到数据库、配置文件或URL参数里;文件加密输出的是带有自定义格式的二进制文件,适合做配置文件保护、导出数据加密、备份文件加密等场景。
1.3 方案选型背后的几个关键决策
我把这套方案里的关键决策点以及做出这些决策的原因整理成了一张表,方便你理解每个环节为什么这么设计:
| 决策点 | 我的选择 | 原因 |
|---|---|---|
| 密钥长度 | 256位 | 安全性高,性能损失可以忽略 |
| 加密模式 | CBC | 需要IV保护,安全性好;CTR、GCM等模式实现复杂度更高 |
| 填充方式 | PKCS7 | 标准填充,C#内置支持,处理任意长度明文 |
| 密钥派生 | Rfc2898DeriveBytes(PBKDF2) | 比直接对密码做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 一个小工具的完整调用示例
光看类不够直观,我给这个类配一个控制台Demo:
using System; class Program { static void Main() { string password = "MySecret@2024"; 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天生就是为流式加密设计的。你把一个输入流喂给它,它把加密后的数据推到输出流,底层按块处理,内存占用基本是恒定的,跟文件大小无关。这也是为什么FileStream+CryptoStream这套组合在C#里处理文件加密几乎是标准解法。
3.2 自定义文件格式设计
开始写代码前,先定义加密文件的存储格式。我用的格式是按字节排列的:
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 4字节 | 版本标识(固定为1) |
| 4 | 4字节 | Salt长度 |
| 8 | Salt长度 | Salt字节 |
| 8+Salt长度 | 4字节 | IV长度 |
| 12+Salt长度 | IV长度 | IV字节 |
| 16+Salt长度+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是目前最经典的KDF,C#里的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版本时,能帮你瞬间发现回归问题。加密这种东西,一旦线上出了问题,排查成本远高于写测试的成本,提前把网织好,后面才能睡得踏实。