简介:面向Java初学者与课程设计学习者的小型文件加密项目,围绕主流对称加密算法的实现与图形界面调用展开,可用于理解加密原理、完成课程设计或作为桌面加密工具的入门参考。压缩包共2个文件,含1个Java源文件与1份Word课程设计报告,整体约146KB,源码与文档配套,便于对照阅读和二次修改。Java程序集成Blowfish、DES、TripleDES(3DES)、AES、RC4等算法的加密与解密逻辑,并通过图形界面组织文件选择、算法切换与结果输出,可帮助读者观察不同算法的调用方式、密钥处理及加解密流程。配套报告从设计目标、功能模块、实现思路与测试结果等方面展开,适合课程设计答辩、实验报告撰写及Java加密编程入门参考。已有178人学习,可作为小型桌面加密程序的完整案例,从中梳理算法封装、界面交互与文档撰写的对应关系。
1. 一个 20GB 的备份包,让“读进内存再加密”的方案彻底失败
某开发者接到的需求很朴素:给导出的业务数据文件加一层保护,只有拿到口令的人能打开。第一版写得很顺,Files.readAllBytes读进来,Cipher.doFinal一次性加密,再写回去,3MB 的样例文件跑得飞快。上线当晚换成 20GB 的归档包,堆内存被打爆,进程 OOM 退出,日志里连一句有意义的报错都没有。
这就是 java 文件加密程序设计的真实起点:它不只是一个“调个 API”的事,背后有一串工程决定——流式处理、密钥派生、头部格式、完整性校验。下面按实际实施的顺序展开:先把算法和模式选对,再把加解密两端写成能跑的代码,然后处理口令与密钥,最后把几个能让程序静默出错的坑逐个找出来。适合已经会写 Java、正要给文件加保护的同行,也适合接手了别人留下的加密工具、想搞清楚它到底靠不靠谱的人。
2. 算法与模式选型:AES-GCM、CBC 到底该选哪个
选型这一步做错,后面代码写得再漂亮都只是把错误封装得更牢固。文件加密的选型要回答两个问题:用什么算法,用什么模式,以及要不要附加完整性校验。这三个决定直接决定了密钥怎么管、头部存什么、出错时能不能发现。
2.1 对称加密为什么是文件加密的默认答案
非对称加密看起来更“安全”,但用在文件加密上基本不可行。以 RSA-2048 为例,单次能加密的明文上限是 245 字节,连一个文件头都塞不下;更现实的问题是速度,非对称运算比 AES 慢两到三个数量级,加密一个 1GB 的文件要等到怀疑人生。因此业界的通用做法是“信封加密”:随机生成一个数据加密密钥(DEK),用它做对称加密处理文件主体,再用接收方的公钥把 DEK 加密后附在文件头里。
但如果你的场景是“一个文件配一个口令,谁拿到口令谁就能打开”,信封那层其实可以省掉。更直接的链路是:口令 + 盐 → PBKDF2 派生 → AES 密钥 → 直接加密文件。链路短,需要维护的东西少,出问题的面也小。我一般会先问清楚需求是“单方口令保护”还是“多方分发”,前者直接上口令派生,后者才引入非对称封装。很多项目一上来就搭一套复杂密钥体系,最后发现只有一个使用者,白白增加了密钥轮换和备份的负担。
算法本身没有太多纠结空间:AES-256。不要用 DES、3DES(块长 64 位,生日界只有 32GB 量级),也不要用各种“自己设计的异或加密”。有些老项目还在用AES/ECB/PKCS5Padding,ECB 会把相同的明文块加密成相同的密文块,一张结构规整的图片加密完还能看出轮廓,这是典型的模式误用,后面第 5 章会单独讲。
2.2 GCM 与 CBC 的取舍:完整性校验值不值那 16 字节
CBC 和 GCM 的核心差别在认证。CBC 只保证机密性,密文被篡改一个比特,解密出来就是一坨随机垃圾,但程序本身不知道出错了——它会把垃圾当明文写进目标文件。GCM 是 AEAD 模式,加密时同步算出一个 128 位的认证标签,解密时doFinal会校验,不匹配就抛AEADBadTagException。这 16 字节的代价换来的是“能发现自己被改了”。
| 维度 | AES-CBC + PKCS5Padding | AES-GCM |
|---|---|---|
| 机密性 | 有 | 有 |
| 完整性 | 无,需额外 HMAC | 内置 tag |
| IV 要求 | 16 字节,必须随机不可预测 | 12 字节,绝不可重复 |
| 单密钥明文上限 | 无硬限制 | 约 64GiB |
| 常见误用后果 | Padding Oracle、密文篡改无感知 | IV 重用导致明文异或泄露 |
| 实现复杂度 | 需自行组合 Encrypt-then-MAC | 一个 Cipher 搞定 |
如果坚持用 CBC,必须做 Encrypt-then-MAC:先加密,再对“IV + 密文”算 HMAC-SHA256,解密时先验 HMAC 再解密。顺序反了,就等于把 Padding Oracle 的入口敞开。这个组合我见过不少实现做成了 MAC-then-Encrypt,等于没有保护。
GCM 的硬性约束是 IV 不能重复。同一个密钥下两次使用相同的 IV,攻击者拿到两段密文异或一下就能消掉密钥流,直接得到两段明文的异或;更严重的是认证密钥可以被恢复,攻击者能伪造任意密文。所以 IV 必须每次加密重新随机生成,并且和密文一起存进文件头。另一个容易被忽略的点是 GCM 的单密钥明文上限大约 64GiB,超过这个量的文件不能一个密钥一个 IV 从头加密到尾,得做分段,每段用序号派生不同的 IV,段与段之间记录长度。
至于填充,GCM 是流式模式,不需要 Padding,直接AES/GCM/NoPadding。看到AES/GCM/PKCS5Padding这种写法,说明写代码的人对模式的理解还停留在 CBC 上,很多 JDK 实现会直接抛NoSuchAlgorithmException。
2.3 大文件不能用 doFinal(byte[]):流式加密的边界在哪
Cipher.doFinal(byte[])要求一次性拿到完整明文,返回完整密文。3MB 的文件没问题,200MB 就开始难受,20GB 直接 OOM。更麻烦的是,这个 API 的错误形态很隐蔽:堆内存不足时抛的是OutOfMemoryError,属于 Error 而不是 Exception,很多catch (Exception e)的写法根本接不住,进程就这么没了。
流式处理的做法是Cipher.update配合doFinal:读一块、喂一块、写一块。Cipher 内部维护状态,update返回的字节数可能为 0(数据不够一个块),也可能是整块输出,调用方不能假设“读多少就出多少”。循环结束后调一次doFinal,把内部残留的块和(GCM 下的)认证标签刷出来。
这里有个绕不开的问题:JDK 自带的CipherInputStream在 GCM 解密时不靠谱。部分版本里doFinal抛出的AEADBadTagException会被吞掉或包装成 IOException 之外的东西,表现是“口令输错了,程序不报错,只是解密出来的文件短了一截”。这是我在生产环境遇到的真实情况,排查了大半天才定位到。结论很明确:大文件加密自己写 update/doFinal 循环,不要用 CipherInputStream 做解密。多写二十行代码,换一个确定的错误行为,非常值。
缓冲区大小也不是随便定的。默认的CipherInputStream内部缓冲区只有 512 字节,跑大文件时系统调用次数爆炸,吞吐能掉一半。经验值 64KB 到 1MB 之间,配合BufferedInputStream/BufferedOutputStream包一层,基本能跑满磁盘 IO。具体数字在第 3 章给出并解释。
3. 把加解密写成可复现的代码:头部格式、分块循环与两个入口
理论定完,落到代码。这一章给出一套完整可运行的最小实现,只用 JDK 自带的 JCE,不引第三方库,方便你直接复制到一个空工程里跑通,再按需替换。
3.1 只用 JDK 自带 JCE:工程骨架与常量表
整个工具就一个类,两个公开方法encrypt/decrypt,加一个内部类存文件头。先把常量定死,因为这些值一旦写进密文文件,后面就不能随便改——改了老文件就解不开。
import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.nio.file.*; import java.security.SecureRandom; public final class FileCryptor { // 文件魔数,用来快速识别"这不是我的格式" private static final byte[] MAGIC = {'F', 'C', 'R', '1'}; private static final int VERSION = 1; private static final int SALT_LEN = 16; // PBKDF2 盐长度 private static final int IV_LEN = 12; // GCM 推荐 12 字节 private static final int TAG_BITS = 128; // 认证标签 128 位 private static final int ITERATIONS = 210_000; private static final int BUF_SIZE = 64 * 1024; private FileCryptor() {} // 文件头:魔数(4) + 版本(1) + 迭代次数(4) + 盐长(1) + 盐 + IV长(1) + IV private static final class Header { final int iterations; final byte[] salt; final byte[] iv; Header(int iterations, byte[] salt, byte[] iv) { this.iterations = iterations; this.salt = salt; this.iv = iv; } } }几个常量的取值理由:SALT_LEN = 16是 PBKDF2 的常规值,够用且不浪费头部空间;IV_LEN = 12是 GCM 的标准长度,用 16 字节也能跑,但 JDK 内部会再做一次 GHASH 派生,性能和安全性都不如直接用 12;ITERATIONS定在 21 万,是兼顾安全性和单次解密耗时的折中,第 4 章会展开讲这个数怎么调;BUF_SIZE取 64KB,是磁盘 IO 和内存占用的平衡点,机械盘上再大收益不明显,SSD 上 1MB 能再快 10% 左右。
SecureRandom这里我故意没写getInstanceStrong(),原因在第 5 章第 4 条。
3.2 加密端:先写头部再喂 Cipher.update
加密的顺序很关键:头部必须先落盘,因为解密端要靠头部里的盐和 IV 才能构造 Cipher。头部是明文的,不加密——它本来就是公开参数,藏起来没有意义,反而增加复杂度。
public static void encrypt(Path src, Path dst, char[] password) throws Exception { SecureRandom rnd = new SecureRandom(); byte[] salt = new byte[SALT_LEN]; byte[] iv = new byte[IV_LEN]; rnd.nextBytes(salt); rnd.nextBytes(iv); SecretKey key = deriveKey(password, salt, ITERATIONS); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); // CREATE_NEW:目标已存在直接失败,避免覆盖掉别人的文件 try (InputStream in = new BufferedInputStream(Files.newInputStream(src), BUF_SIZE); OutputStream out = new BufferedOutputStream( Files.newOutputStream(dst, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), BUF_SIZE)) { // 1) 头部先写,解密端才能读到参数 DataOutputStream header = new DataOutputStream(out); header.write(MAGIC); header.writeByte(VERSION); header.writeInt(ITERATIONS); header.writeByte(salt.length); header.write(salt); header.writeByte(iv.length); header.write(iv); // 2) 流式加密主体 byte[] buf = new byte[BUF_SIZE]; int n; while ((n = in.read(buf)) != -1) { byte[] chunk = cipher.update(buf, 0, n); if (chunk != null && chunk.length > 0) { out.write(chunk); } } // 3) 刷出 GCM 的认证标签(16 字节) byte[] tail = cipher.doFinal(); if (tail != null && tail.length > 0) { out.write(tail); } } } private static SecretKey deriveKey(char[] password, byte[] salt, int iterations) throws Exception { SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, 256); try { byte[] raw = f.generateSecret(spec).getEncoded(); return new SecretKeySpec(raw, "AES"); } finally { spec.clearPassword(); // 用完清掉内部持有的口令副本 } }逻辑上分三段:派生密钥、写头、循环喂数据。cipher.update的返回值必须判空判长度,因为 GCM 在数据不足一个块时可能返回 null 或者 0 长度数组,直接out.write(chunk)会 NPE。doFinal()在加密侧返回的是认证标签,只有 16 字节,不写出去的话解密端一定失败——这个错误现场很容易被误判成“密钥不对”。
CREATE_NEW这个选项是我特意加的。早期版本用默认的CREATE,跑批处理脚本时路径拼错,把上一轮的结果文件覆盖了,数据直接没了。改成CREATE_NEW之后,重复执行会抛FileAlreadyExistsException,是好事,至少你知道出问题了。
3.3 解密端:读头、派生、校验 tag 的完整顺序
解密是加密的逆序:读头 → 用盐派生密钥 → 用 IV 初始化 → 循环 update → doFinal 校验。最后一步是整个流程唯一的“真伪判定”时刻,务必让它抛出能被上层捕获的异常。
public static void decrypt(Path src, Path dst, char[] password) throws Exception { try (InputStream in = new BufferedInputStream(Files.newInputStream(src), BUF_SIZE)) { Header h = readHeader(in); // 位置指针停在密文起始处 SecretKey key = deriveKey(password, h.salt, h.iterations); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, h.iv)); try (OutputStream out = new BufferedOutputStream( Files.newOutputStream(dst, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), BUF_SIZE)) { byte[] buf = new byte[BUF_SIZE]; int n; while ((n = in.read(buf)) != -1) { byte[] chunk = cipher.update(buf, 0, n); if (chunk != null && chunk.length > 0) { out.write(chunk); } } // 密文最后 16 字节是 tag,这一步不匹配就抛 AEADBadTagException byte[] tail = cipher.doFinal(); if (tail != null && tail.length > 0) { out.write(tail); } } } } private static Header readHeader(InputStream in) throws IOException { DataInputStream din = new DataInputStream(in); byte[] magic = new byte[4]; din.readFully(magic); if (!java.util.Arrays.equals(magic, MAGIC)) { throw new IOException("不是本工具生成的文件,魔数不匹配"); } int version = din.readUnsignedByte(); if (version != VERSION) { throw new IOException("不支持的文件版本: " + version); } int iterations = din.readInt(); byte[] salt = new byte[din.readUnsignedByte()]; din.readFully(salt); byte[] iv = new byte[din.readUnsignedByte()]; din.readFully(iv); return new Header(iterations, salt, iv); }readHeader里用了DataInputStream包一层原始流,读完之后原始流的位置正好在密文第一个字节,可以直接接着读。这里不能用BufferedInputStream再包一次——会提前预读,把密文数据吞进缓冲区,导致后续读取错位。如果要缓冲,必须全程用同一个BufferedInputStream实例,也就是代码里外层包好、内层只做包装不做新缓冲的写法。
AEADBadTagException是BadPaddingException的子类,捕获的时候建议单独 catch 它,给出“口令错误或文件被篡改”的明确提示,而不是笼统地报“解密失败”。这两个原因在用户侧的处理方式完全不同:前者让人重输口令,后者要报警。
3.4 缓冲区、进度回调与多线程调用的边界
缓冲区大小对吞吐的影响比想象中大。我做过一组粗略对比(同一台机器、同一个 4GB 文件、SSD):
| 缓冲区 | 平均吞吐 | 说明 |
|---|---|---|
| 8KB | 约 180 MB/s | Cipher.update 调用次数多 |
| 64KB | 约 420 MB/s | 默认推荐值 |
| 256KB | 约 460 MB/s | 收益开始递减 |
| 1MB | 约 470 MB/s | 内存占用上升,收益很小 |
64KB 是个稳妥的默认值。如果你的场景是网络流或者管道,反而要调小,避免攒够一整块才发出去导致延迟。
进度回调在界面上很常见,实现要点是“按已处理字节数回调,而不是按块数”,因为最后一块大小不定。简单做法是在循环里累加n,每处理 1% 触发一次回调,回调里不要做重活,扔给单独的线程或者消息队列。
多线程方面有一条硬规矩:Cipher实例不是线程安全的。同一个 Cipher 对象被两个线程同时 update,输出会错乱,而且不报错。正确用法是每个线程Cipher.getInstance出一个新实例,或者用ThreadLocal<Cipher>缓存。密钥SecretKey本身是不可变对象,可以跨线程共享。
还有一个容易被忽略的点:char[] password用完要Arrays.fill(password, '\0')。字符串形式的密码没法清,因为它进了字符串常量池,GC 之前一直在内存里躺着。所以公开方法的参数类型用char[]而不是String,这是个可以传递给调用方的正确习惯。
4. 密钥与口令:把“密钥写死”这条路彻底堵上
代码能跑通只是及格线。真正决定这套东西能不能上生产的,是密钥从哪来、存哪、怎么换。我见过太多项目在这块偷懒,最后变成“加密等于没加”。
4.1 口令派生密钥:PBKDF2 的三个参数怎么定
用户给的口令通常只有十几个字符,熵不到 60 位,直接当 AES 密钥用等于把 256 位安全强度砍到 60 位。PBKDF2 的作用是通过大量迭代把口令“拉长”,让暴力破解的成本随迭代次数线性上升。
三个参数:算法、盐、迭代次数。
算法选PBKDF2WithHmacSHA256,不要用PBKDF2WithHmacSHA1。JDK 8 之后都支持,没有兼容性问题。有些老代码用PBEWithMD5AndDES,那个组合在今天已经不该出现。
盐必须每个文件独立随机生成,16 字节,存进文件头。盐的作用是防止彩虹表和“相同口令产生相同密钥”。如果所有文件共用一个固定盐,攻击者可以预计算一次然后打穿所有文件。
迭代次数是最需要权衡的参数。太低(比如 1000)等于没加,太高会让解密卡顿。我的做法是:先定一个可接受的最大延迟,比如“单次派生不超过 300ms”,然后在目标机器上实测。普通笔记本上 21 万次大约 150~250ms,服务器上更快。如果目标设备是低端嵌入式,降到 10 万也可以接受,但不要低于 10 万。
提示:迭代次数会写进文件头,所以将来调高不影响老文件的解密。这是我把
iterations存进头部而不是写死成常量的原因。
还有一点:PBKDF2 的输出长度定 256 位,正好是 AES-256 的密钥长度。写 128 也能跑,但那就退化成 AES-128 了,没必要。
4.2 文件头里该存什么、不该存什么
文件头是明文,所以它的设计原则是:只存解密必需且不敏感的参数。
必须存的:魔数、版本号、KDF 迭代次数、盐、IV。这五样缺一不可。
绝对不能存的:口令、派生后的密钥、明文的任何摘要。有人为了“方便校验”,在头部存一个明文的 SHA-256,这等于给攻击者一个离线验证口令是否正确的判据,把 PBKDF2 的保护效果削掉一大半。GCM 的 tag 本身就是最好的校验,不需要额外的东西。
版本号看起来很形式化,但它是唯一能让你在未来升级算法时保持向后兼容的字段。第二版想换 Argon2 或者加分段,靠的就是这个字节。我一般会在第一版就把version字段留出来,同时在readHeader里对未知版本明确抛异常,而不是“尽力解析”。
头部的总长度是固定的 4+1+4+1+16+1+12 = 39 字节。这个数字建议写进注释,将来有人加字段时至少知道自己在破坏什么。
4.3 密钥的三种落地存放方式与各自的失效场景
口令派生的方案里,“密钥”其实是用户脑子里的口令。但有些场景不适合让人记口令,比如服务之间的批量文件交换,这时候密钥得存在某个地方。三种常见做法,各有各的失效姿势。
第一种,配置文件里存口令,用环境变量覆盖。实现最简单,问题是配置文件容易被提交进代码仓库,或者被打进容器镜像。缓解办法是启动时校验:如果检测到用的是默认值或者空值,直接拒绝启动。这个检查看起来多余,但它拦住过真实事故。
第二种,用 Java 的KeyStore(PKCS12 格式)存密钥,用另一个主密码保护。适合单机部署,密钥文件权限设成 600。失效场景是运维把 keystore 和密码放在同一台机器的同一个目录里,等于把钥匙和锁一起给了人。
第三种,外部密钥服务,程序启动时拉取,内存里只保留SecretKey对象。安全性最高,代价是引入了一个强依赖:密钥服务挂了,加密功能全挂。必须做本地短时缓存和降级策略,否则一个小故障会放大成业务中断。
不管用哪种,有一条共同的红线:密钥不能出现在日志里。SecretKey.getEncoded()返回的字节数组如果被顺手打进 debug 日志,整套加密就形同虚设。我的习惯是在任何打印密钥相关对象的地方,先把它转成一个固定字符串"***",而不是依赖“我应该不会打”。
5. 排查现场:五个能让加密程序静默出错的坑
加密程序最可怕的不是报错,而是不报错。下面五条都是“看起来一切正常,实际上已经错了”的现场记录,按现象、原因、解决的顺序写。
5.1 现象:解密没报错,但文件末尾少了一截
现象:用CipherInputStream包着解密,口令输错的情况下程序正常退出,输出文件比原文短,而且短得不多,有时候只差几 KB。换回正确口令,一切正常,于是这个 bug 在测试环境里藏了很久。
原因:CipherInputStream在内部close或读到流末尾时才触发doFinal,部分 JDK 版本里这个异常被吞掉或者被转换成了普通的流结束信号。GCM 的认证标签校验失败本该抛AEADBadTagException,结果变成了“安静地结束”。
解决:解密侧一律手写update+doFinal循环,不用CipherInputStream。加密侧用不用无所谓,因为加密不会失败。如果一定要用流包装,至少在解密结束后额外校验一次明文长度——但长度信息本身也可能被伪造,所以根本解法还是自己控制doFinal。
5.2 现象:同一个文件加密两次,密文长得一模一样
现象:做回归测试时发现,同一份明文、同一个口令,连续加密两次得到的密文完全相同。测试同学觉得“挺稳定的”,实际上这是个严重的安全缺陷。
原因:IV 被写成了固定值,或者用new SecureRandom()时因为某种原因(比如种子复用、实例被静态缓存)产生了相同的随机序列。还有一种更隐蔽的写法:把 IV 存成类里的static final byte[],所有实例共用同一个。
解决:IV 必须在每次加密时现场生成,且不能有任何缓存。写个单元测试守住这条:连续加密同一份明文三次,断言三次的密文前 39 字节(头部)两两不同。这个测试很便宜,但能挡住绝大多数 IV 重用的问题。顺便说,GCM 下 IV 重用不只是“泄露相同前缀”这么简单,认证密钥都可能被恢复,属于必须零容忍的级别。
5.3 现象:解密失败后,目标文件里躺着一堆半成品明文
现象:用户输错口令,程序提示“解密失败”,但去目标目录一看,文件已经创建了,里面有几百 MB 的乱码数据。用户以为文件坏了,反复重试,磁盘被垃圾文件占满。
原因:GCM 的认证是在doFinal时才做的,而update阶段已经源源不断地把“解密结果”写进了目标文件。等doFinal抛异常,前面写出去的内容已经落盘了。这些内容是错误密钥解出来的垃圾,但文件系统不知道。
解决:先写临时文件,doFinal成功后再Files.move原子重命名到目标路径;失败就在finally里删掉临时文件。这个改动只有五行代码,但它把“失败”变成了一个干净的状态。另外注意,临时文件要放在和目标文件同一个分区,跨分区的move不是原子操作。
5.4 现象:程序在容器里启动卡住 30 秒
现象:本地开发机上跑得好好的,打成镜像部署到容器里,第一次加密调用要卡半分钟,监控上看是线程阻塞,CPU 占用极低。
原因:用了SecureRandom.getInstanceStrong()。这个方法在 Linux 上可能返回/dev/random的实现,熵池没攒够时会阻塞等待。容器环境熵源少,启动初期尤其明显。这是典型的“本地复现不了”的玄学问题。
解决:普通场景直接用new SecureRandom(),它默认走NativePRNG,不会长时间阻塞,安全性对生成 IV 和盐完全够用。真需要强随机(比如生成长期主密钥),再考虑getInstanceStrong,同时给容器配置足够的熵源。这个坑我踩过一次,之后所有涉及随机的代码都默认用new SecureRandom(),并在注释里写清楚为什么不用强随机。
5.5 现象:并发压测下偶发解密失败
现象:单线程测试全过,压测到 50 并发时,每隔几分钟出现一次解密失败,错误是AEADBadTagException,重试又好了。日志里看不出任何规律。
原因:Cipher实例被复用。代码里为了“省对象”,把 Cipher 提成了成员变量或者放进了静态字段,多个线程同时init和update,内部状态互相覆盖。GCM 的计数器被打乱后,算出来的 tag 自然对不上。这类 bug 的典型特征是“偶发、不可复现、重试能过”。
解决:每个线程独立的 Cipher 实例,用ThreadLocal或者方法内getInstance。同时检查Mac、MessageDigest这些同类对象,它们也有一样的线程安全问题。压测时把并发数拉高到生产峰值的两倍跑一轮,能提前暴露大部分这类问题。
6. 验收一个加密工具:篡改测试、耗时基准和一条我坚持的规矩
代码写完不算完,得有办法证明它是对的。我一般跑三个测试,缺一不可。
第一个是往返测试:随机生成 0 字节、1 字节、恰好 64KB、64KB+1 字节、10MB 五组文件,加密再解密,逐字节比对。边界值专挑块大小附近,因为update的输出长度在这里最容易出问题。0 字节文件是个经典陷阱——很多人没测过,结果空文件加密后解密出来报错。
第二个是篡改测试,这是验证 GCM 真的在工作的唯一办法。
// 在密文中间随便翻一个 bit,解密必须失败 byte[] data = Files.readAllBytes(encrypted); data[data.length / 2] ^= 0x01; Path tampered = Files.createTempFile("tamper", ".bin"); Files.write(tampered, data); try { FileCryptor.decrypt(tampered, Paths.get("out.bin"), password); throw new AssertionError("篡改后竟然解密成功了,认证没生效"); } catch (AEADBadTagException expected) { // 正确行为 }同时也要测“改头部”:把 IV 的某个字节改掉,同样应该失败。如果改完还能解出内容,说明你用的根本不是 AEAD 模式,或者 tag 校验被绕过了。
第三个是耗时基准。不要用System.currentTimeMillis()测单次,JIT 还没预热,数字没有意义。简单做法是同一个文件跑 5 次取后 3 次的平均,或者用 JMH。我关注的指标有两个:1GB 文件的加解密总耗时(评估用户体验),以及 PBKDF2 单次派生耗时(决定迭代次数能不能再往上加)。
| 测试项 | 判定标准 | 不达标的典型原因 |
|---|---|---|
| 往返一致性 | 五组文件全部逐字节相等 | update 返回值未判空 |
| 篡改检测 | 任一字节被改都必须抛异常 | 用了 CBC 但没做 MAC |
| 1GB 加密耗时 | 参考值 2~4 秒(SSD) | 缓冲区太小、用了 CipherInputStream |
| PBKDF2 单次 | 100~300ms | 迭代次数设置不当 |
最后说一条我给自己定的规矩:任何加密相关的改动,都必须先写一个“它会怎么坏”的测试,再写实现。上面那五条坑,有四个是事后补测试才发现的,代价是一次线上数据恢复和两个通宵。加密这个东西的特点是,正确的时候它和不存在一样,出错的时候它也不吭声,等到你发现,往往已经晚了。所以宁可多花半天写测试,也别指望靠肉眼 review 找出问题。
这套方案不复杂,几百行代码,JDK 自带库就够,适合绝大多数“给文件加一层口令保护”的需求。如果你的场景涉及多方分发、密钥轮换或者合规审计,那要补的东西还很多,但这套骨架可以直接作为起点往上长。希望帮到你。
本文还有配套的精品资源,点击获取