看到这个标题,我脑子里先冒出来的是某次在银行类JavaWeb项目里被大文件上传“放倒”的场景:客户经理传了一个40多MB的客户资料压缩包,后端用byte[]一口气读进内存,Tomcat 直接内存溢出,页面咔一下白屏。后来问题修好了,但真正让我后背发凉的,是那个压缩包里几乎全是身份证、银行卡、手机号这类客户敏感信息。如果当时的处理方式是先落明文再想办法加密,那这就不是一次普通的性能事故,而是一次实打实的数据安全事件。
这个标题其实拆成三个问题来答比较省力:JavaWeb 层怎么接住大文件上传;在上传链路里怎么对敏感客户数据做加密;银行这种安全等级要求极高的场景下,加密不能只是“有”,还要经得起安全审计和密钥轮换。这篇就按这三层往下展开,读完你既可以拿到能落地的算法和代码,也能避开我踩过的那些坑。
1. 先拆解需求:到底哪些敏感数据、躲在哪一层?
1.1 大文件里的敏感数据,远不止“身份证号”一个字段
很多人一看到“敏感客户数据”,第一反应是数据库里那几列:身份证号、手机号、银行卡号。但在大文件上传这个场景里,问题要麻烦得多。客户经理传上来的往往是一个贷款资料包、一份征信报告PDF、一组合同扫描件,甚至是一个几十MB的Excel台账,里面的敏感信息是以整个文件为载体的。
我接触过的典型场景是信贷审批系统:客户提交的资产证明、收入流水、房产证复印件,全部打包压缩,压缩包轻松上50MB。这种文件里没有任何“字段”概念——你不能像处理数据表一样,把某个单元格抠出来单独加密。文件名本身也可能泄露信息,比如一个文件直接叫“张三_139xxxx8888_结婚证.pdf”,这种情况下如果只加密文件内容不处理文件名,等于把客户隐私贴在门牌上。
所以大文件上传的加密,核心对象是整个文件实体,外加文件名等元数据。字段级加密只能用在文件落库之后的结构化数据上,两者是两条平行线,不能混为一谈。
1.2 银行场景的加密底线:不只是HTTPS,而是落盘即密文
银行类系统过安全评审的时候,内部安全团队必问几个问题:文件落到磁盘上是什么形态?如果数据库或者文件服务器被拖走,别人能不能直接读出客户内容?备份磁带丢了能不能还原出明文?密钥存在哪里、多久轮换一次?
这些问题背后的底线是一致的:传输过程要可靠,存储状态必须是密文,密钥和文件要分离。很多研发习惯性以为“我上了HTTPS就加密了”,这是完全不够的。HTTPS解决的是数据在网络传输中被窃听的问题,它到服务器接入层就终止了。从网关到应用、从应用到磁盘,这条路上的数据在业务层仍然是明文。对于银行级系统,业务层面的落盘加密不是可选项,而是默认项。
这一点想清楚之后,后续所有设计才有了基调:我们不能先接收明文文件、再额外包一层加密任务,而要在接收文件的那一刻就用密文形态写盘。
1.3 先排雷:那些“以为加密了其实没加密”的典型错误
动手之前,先把我在各种项目里见过的反面教材列出来,省得后面再踩:
- 对文件名做MD5/SHA,内容却明文存储。文件一打开,什么隐私都在里面,等于脱了裤子防色狼。
- 使用DES、3DES、ECB模式。算法早已过时,ECB模式下相同的明文块会生成相同的密文块,文件特征根本藏不住。
- 全系统共用一个固定密钥,还硬编码在代码或者配置文件里。密钥一旦泄露,所有客户数据全部裸奔。
- 先把整个文件读进
byte[]再做加解密。几十MB文件直接干爆堆内存,属于大文件处理最常见的死法。 - 把密文转成Base64存数据库。Base64会让体积膨胀约33%,大文件场景下磁盘和查询成本同时失控。
这些坑不是理论上的,是真的一家家踩过来的。理解了这些问题,下面选型时的很多取舍就顺理成章了。
2. 加密选型:为什么大文件场景默认走AES-GCM和信封加密?
2.1 AES-GCM vs AES-CBC vs SM4:模式选择背后的安全逻辑
对称加密算法本身可选范围不大,银行场景常见的是AES和国密SM4。真正让研发犯难的是分组模式的选择。下表是三个常用选项的对比:
| 维度 | AES-CBC | AES-GCM | SM4-GCM |
|---|---|---|---|
| 是否支持完整性校验 | 不支持,需另拼HMAC | 原生支持(AEAD) | 原生支持(AEAD) |
| 加密数据是否可篡改检测 | 无法直接感知 | 解密时自动校验 | 解密时自动校验 |
| 并行处理友好度 | 一般 | 有限(计数器状态连续) | 有限 |
| 常用场景 | 传统磁盘加密 | 大文件、网络传输、敏感数据落盘 | 有国密合规要求时 |
| 性能(有AES-NI时) | 较快 | 较快 | 取决于实现 |
CBC模式只解决了“加密”问题,不解决“防篡改”问题。在大文件场景里,文件可能被中间人替换、被截断、被篡改,如果没有完整性校验,密文被偷偷改掉一部分,解密出来的文件可能是一个逻辑错误的文件,系统却完全察觉不到。AES-GCM是AEAD模式,加密过程中会生成一个认证标签,解密时如果数据被改动任何一个字节,直接抛出认证失败异常。这个特性对银行文件来说太重要了,因为客户资料的完整性和机密性同等重要。
我个人的倾向是:没有国密合规约束,直接上AES-256-GCM;有国密要求,就用SM4-GCM配套的国密算法体系。性能方面,现代CPU基本都支持AES-NI指令集,1MB缓冲区下加密吞吐量能到几百MB/s,对常见的大文件上传影响完全可以接受。
2.2 文件级加密和字段级加密要配合使用
有人会问,文件里面全是身份证号码,能不能只加密这些号码,其他内容保持明文,这样处理起来效率高?理论上可以,但实践上行不通。
PDF、图片、Office文档这些格式内部是高度结构化的二进制布局。你想在已经写入磁盘的文件里定位到某个字符串并做原位加密,要么破坏文件结构,要么需要极其复杂的解析逻辑。银行系统里的文件来自五花八门的业务系统,你做字段级模糊替换,做一次坏一次。
所以我的建议是两条腿走路:
- 结构化入库数据(如数据库里的客户手机号、账户余额等)走字段级加密,在DAO层用AES-GCM加解密,每个字段随机IV。
- 非结构化文件(PDF、图片、压缩包、视频等)走文件级整体加密,文件无论多大,从接收开始就整体密文落盘。
这两套体系可以共用同一个密钥管理服务,但算法参数、密钥维度、元数据模型建议分开建,避免一张表里既存字段密文又存文件密文,运维混乱。
2.3 信封加密:密钥轮换的工程命门
密钥管理是银行系统里最容易让方案翻车的环节。很多项目用一把固定密钥加密所有文件,看起来简单,但安全审计一查“密钥轮换方案”就直接卡死:如果你要轮换主密钥,难道把所有历史文件全部重新解密再加密?几十TB的文件重加密一遍,这是不可接受的工程量。
所以必须引入信封加密模型:
- 系统只保留一把或少数几把主密钥(KEK),由KMS、HSM或独立密钥服务器保管。
- 每上传一个文件,随机生成一把数据密钥(DEK),用这把DEK加密文件内容。
- 再用主密钥KEK加密DEK,得到的
DEK密文存储在数据库元数据表里。 - 使用时,先用KEK解出DEK,再用DEK解开文件。
打个比方:主密钥是保险柜的主钥匙,每份文件有一把自己独立的柜门钥匙。你要取文件,先用主钥匙打开保险柜,取出这份文件的柜门钥匙,再去开文件柜。这样单个文件密钥泄露不会波及其他文件,主密钥轮换时只需要把存量DEK密文重新用新主密钥加密一遍,文件本体完全不用动。
这个模型不是可选项。银行审计问“你怎么做密钥轮换”时,没有信封加密几乎没法回答。
3. 上传链路设计:如何做到明文不落盘,还能兼容分片和断点续传?
3.1 明文的生命周期:传输层、应用层、存储层三层视角
设计上传链路时,我会把“明文”当作一种危险物质,凡是能减少它出现时间的地方都要减少。一条完整链路通常是这样:
客户端发起HTTPS请求,到接入层(Nginx/网关)TLS终止,这个环节解决传输加密;请求进入Java后端后,通过MultipartFile的输入流读取分片内容;在应用层,我们不把分片内容缓存在内存数组,而是马上交给CipherOutputStream,加密后直接写入磁盘临时文件或对象存储。
这个链路的关键点在Web容器配置。很多项目死在默认配置上:Tomcat的maxPostSize默认2MB、maxSwallowSize默认也是2MB,大文件上传直接报错。要支持大文件,必须把这两个参数调大,并且用分片而不是一次性POST整个文件。另一方面,MultipartResolver解析时如果文件被暂存到了临时目录,默认是明文暂存。这里要特别检查配置,确保上传组件不会在应用层把明文写到临时目录,否则后面的加密就是白做。
我的建议是,在后端验收时加一条“线上扫描”规则:用文件监控工具查看应用临时目录,上传期间不允许出现明文客户文件。
3.2 分片上传和GCM状态机如何共存
大文件上传通常要分片,否则单次请求体积太大,网络抖动就全盘重传。但分片和AES-GCM有一个天然的矛盾:GCM是带有状态的分组模式,计数器必须连续,不允许中间重置。如果每个分片在服务端独立初始化一个Cipher再加密,最后拼接起来,会导致IV或计数器重复使用,这在密码学上是致命的——两个分片使用相同密钥和相同IV(计数器起点)时,密文会泄露明文信息。
实际工程里可以这么做:
方案一:全局加密流,顺序写入。
前端仍然把文件切成10MB的分片,逐片POST到后端。后端为这个文件维护一个唯一的CipherOutputStream,各分片达到服务端后按顺序追加写入同一个加密流。这样整体文件的加密状态是连续的,GCM的计数器从头到尾不重置。
这个方案适合文件不是特别大(比如2GB以内)、上传并发量可控的场景。实现上要注意:同一文件的分片必须串行写入,除非用锁保证顺序,否则一个分片乱序就会导致整个加密流崩溃。
方案二:分片独立密钥,独立加密。
每个分片单独生成一把DEK和IV,独立加密后存储。元数据表记录每个分片的DEK密文、IV、序号、哈希。下载时按序号把密文分片取出来,分别解密再拼接。
这个方案支持并发分片上传和更好的断点续传,坏处是元数据复杂,且无法防止分片被重排或替换,需要额外用哈希链把分片串起来。除非文件超大且网络条件极差,否则我一般不建议上方案二。
我的建议是:绝大多数银行业务文件都在几十MB到几百MB量级,直接选方案一。实现简单、安全性好、审计也好解释。
3.3 元数据设计:密文文件旁边要跟着一份“开锁说明书”
文件加密之后,解密需要的信息不能丢,否则密文永远躺死在硬盘上。一套完整的文件元数据表至少包含以下字段:
| 字段 | 说明 |
|---|---|
| file_id | 全局唯一文件ID |
| user_id_hash | 归属客户或客户经理的哈希值,用于权限隔离 |
| file_name_cipher | 原始文件名的加密值,绝不存明文 |
| iv | 文件加密时使用的随机IV |
| dek_cipher | 用KEK加密后的DEK密文 |
| key_version | 加密该DEK时使用的主密钥版本 |
| alg | 算法标识,如AES/GCM/NoPadding |
| total_size | 明文文件总大小 |
| sha256 | 明文文件哈希,用于完整性双重校验 |
| upload_status | 上传中/已完成/已失效 |
为什么要存key_version?因为主密钥轮换之后,旧文件必须知道自己是被哪一版主密钥包裹的。如果代码里永远用“当前最新密钥”去解DEK,以前的老文件全都会解密失败。这是我在生产环境里见过最经典的运维事故。
file_name_cipher也要存密文。原始文件名里经常带着客户姓名、手机号、证件号,日志和数据库一旦把明文的文件名打出来,客户隐私就泄了一半。下载时再解密还原文件名,只在响应头Content-Disposition里短暂使用。
4. 核心代码实现:加密服务、分片上传和密钥管理一次讲完
4.1 加密服务封装:流式加解密的核心写法
文件加密最忌讳的就是“一次性读入内存”。核心代码应当基于CipherInputStream和CipherOutputStream做流式处理,缓冲区建议1MB,比8KB的默认缓冲性能好很多。
public static void encryptFile(InputStream in, OutputStream out, SecretKey key, byte[] iv) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); try (CipherOutputStream cos = new CipherOutputStream(out, cipher)) { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = in.read(buffer)) != -1) { cos.write(buffer, 0, len); } } }对应的解密方法就是把Cipher初始化为DECRYPT_MODE,然后用CipherInputStream包装密文输入流。注意一个细节:CipherOutputStream.close()会触发认证标签的生成,所以一定要用try-with-resources确保关闭,否则最后一段数据的加密标签丢失,解密时必然失败。
4.2 分片上传控制层:按序写入同一个加密流
方案一的核心是把每个分片的输入流追加到同一个加密输出流。代码逻辑大致如下:
// 统一管理每个文件的加密输出流,注意单实例时用 ConcurrentHashMap private final ConcurrentHashMap<String, CipherOutputStream> encryptStreams = new ConcurrentHashMap<>(); @PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam("fileId") String fileId, @RequestParam("index") int index, @RequestParam("chunk") MultipartFile chunk) throws Exception { // 1. 校验当前上传文件的状态 FileMeta meta = fileMetaService.get(fileId); if (meta == null || meta.getUploadStatus() != PROCESSING) { throw new IllegalStateException("文件状态异常,请重新创建上传任务"); } // 2. 校验分片序号,必须严格连续 synchronized (fileId.intern()) { if (meta.getCurrentChunk() != index) { throw new IOException("分片序号不连续,请从" + meta.getCurrentChunk() + "重传"); } // 3. 首次上传时初始化加密流 CipherOutputStream cos = encryptStreams.computeIfAbsent(fileId, k -> { try { // 从信封加密中拿到DEK和IV SecretKey dek = kmsService.decryptDek(meta.getDekCipher()); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, dek, new GCMParameterSpec(128, meta.getIv())); return new CipherOutputStream(new FileOutputStream(meta.getEncryptedFile()), cipher); } catch (Exception e) { throw new RuntimeException(e); } }); // 4. 直接写入,注意不要调用 cos.close() try (InputStream in = chunk.getInputStream()) { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = in.read(buffer)) != -1) { cos.write(buffer, 0, len); } } // 5. 更新当前进度 meta.setCurrentChunk(index + 1); } return Result.ok(meta.getProgress()); }这段代码有几个地方容易踩坑:分片写入时不能关闭CipherOutputStream,一关整个文件的加密流就断了;每次分片上传之后,分片的InputStream要自己关闭,但加密流要留到所有分片传完再关闭。对于多实例部署,这种本地ConcurrentHashMap的做法会有问题,建议要么用Redis锁保证同一个文件的写操作路由到同一实例,要么直接用方案二的分片独立加密。
4.3 密钥管理:生成DEK、加密DEK、解密DEK的标准流程
信封加密的实现重点是把“数据密钥”和“主密钥”分开。以下代码展示核心步骤:
// 1. 为每个文件生成随机DEK public static SecretKey generateDataKey() throws Exception { KeyGenerator generator = KeyGenerator.getInstance("AES"); generator.init(256); return generator.generateKey(); } // 2. 用主密钥KEK加密DEK,得到DEK密文 public static byte[] encryptDek(SecretKey dek, SecretKey kek, byte[] kekIv) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, kek, new GCMParameterSpec(128, kekIv)); return cipher.doFinal(dek.getEncoded()); } // 3. 用主密钥KEK解密DEK密文,恢复数据密钥 public static SecretKey decryptDek(byte[] dekCipher, SecretKey kek, byte[] kekIv) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, kek, new GCMParameterSpec(128, kekIv)); byte[] dekBytes = cipher.doFinal(dekCipher); return new SecretKeySpec(dekBytes, "AES"); }生产环境里,KEK本身不该出现在应用代码里,更不该出现在配置文件。理想方案是从KMS服务的本地接口读取,或者对接加密机。如果项目预算和运维能力有限,至少要做到:KEK存储在环境变量或独立配置中心,与代码仓库分离,并且有定期的轮换任务。轮换时只需要遍历文件元数据表,用新KEK重新加密每个DEK即可,文件本体不需要动。
4.4 下载与完整性校验:解密时怎么确认文件没被改
下载是一个反方向的过程,核心是用CipherInputStream包裹密文输入流,边读边解密,边写响应输出流。这里有一个容易被忽略的点:GCM的完整性校验发生在读取流到达末尾的时候。也就是说,只有读完整个文件,认证标签才会被验证。如果文件被截断、篡改或者密钥不对,会在流结束时抛出AEADBadTagException。
@GetMapping("/download/{fileId}") public void download(@PathVariable("fileId") String fileId, HttpServletResponse response) throws Exception { FileMeta meta = fileMetaService.get(fileId); SecretKey dek = kmsService.decryptDek(meta.getDekCipher()); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(kmsService.decryptFileName(meta), "UTF-8")); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, dek, new GCMParameterSpec(128, meta.getIv())); try (InputStream fin = new FileInputStream(meta.getEncryptedFile()); CipherInputStream cis = new CipherInputStream(fin, cipher); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = cis.read(buffer)) != -1) { out.write(buffer, 0, len); } } catch (AEADBadTagException e) { // 认证失败,说明文件被篡改或密钥有问题 throw new IllegalArgumentException("文件完整性校验失败"); } }如果安全要求更高,加密时把明文文件的SHA-256算出来存到元数据表,下载解密完成后重新计算SHA-256比对。GCM标签已经能防篡改,但多加一层明文的哈希可以防止密码算法之外的边界情况(比如密钥被暴力破解后的二次验证)。
5. 常见故障排查:加密上传最容易出问题的几个点
5.1 高频问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
上传过程中OutOfMemoryError | 一次性readAllBytes()、缓冲太小 | 全部改为流式读取,缓冲区设1MB,检查JVM堆 |
| 上传很慢、CPU飙升 | 加密缓冲区太小、未启用AES-NI | 把缓冲区提到1MB,确认JDK支持硬件加速 |
解密报AEADBadTagException | IV或DEK不匹配、密文被截断、分片乱序写入 | 核对fileId对应的IV/DEK,检查分片序号 |
| 所有分片传完,文件解密打不开 | CipherOutputStream被提前关闭 | 确保最后一个分片写完后才关闭加密流 |
| 数据库里DEK字段报错或被截断 | 列类型varchar长度不够 | 用VAR_BINARY/BLOB,或用Base64存满46字符 |
| 多个并发线程写同一文件加密流导致异常 | 并发写同一个CipherOutputStream | 按fileId加锁,保证串行写入 |
| 重启后文件解密失败 | 加密流状态没写入元数据 | 重启前必须关闭加密流,并把进度持久化 |
5.2 安全审计视角下容易翻车的细节
安全审计不是只盯算法,很多项目死在细节上。这里列几条我实际经历过的:
日志脱敏。上传接口的日志千万不要记录文件名、文件内容前几行的明文。日志里只保留fileId、分片序号、文件大小、当前状态这类无敏感信息字段。否则加密做得再好,日志一泄全完。
备份策略。密文文件和密钥绝不能放在同一个备份介质里。备份文件可以只备份密文,密钥要单独走密钥管理系统的备份通道。现实中很多项目密文和密钥放在同一个NAS目录,这等于什么都没做。
密钥版本兼容。代码里解密DEK的时候,必须先读元数据里的key_version,再选择对应版本的主密钥去解。如果你永远用“当前最新的KEK”去解所有的DEK,轮换一次就崩一次。
临时文件权限。即便密文落盘,也不能把临时文件丢到任何人可读的共享目录。文件系统的权限控制要和数据库同级对待,加密是最后一道保险,不是唯一的保险。
6. 项目里的实操体会与可以继续扩展的方向
6.1 我踩过坑之后总结出来的三条经验
第一,安全方案的设计一定要从“密钥轮换”倒推回来。我见过不少团队一开始用固定密钥做文件加密,上线半年后安全部门要求轮换密钥,结果发现所有存量文件都无法解密,只能停机重来。信封加密不是锦上添花,是给未来留后路。
第二,大文件加密的性能问题,绝大多数不是算法太慢,而是工程写法太差。缓冲区设成8KB和设成1MB,同样一个100MB文件,耗时可能差出几倍;用byte[]读整个文件和用流式读取,内存占用能差出几十倍。先把代码写法改对,再看算法本身。
第三,加密这件事要做到“全链路覆盖”,否则等于没做。文件内容加密了,文件名明文;数据库字段加密了,日志明文;传输加密了,临时盘明文。审计的人不看你用了多强的算法,他们看的是明文在系统里出现过多少次。明文出现的环节越少,方案越安全。
6.2 可以继续扩展的方向
这套方案在后面还能往几个方向演进。一个是支持国密SM4-GCM,用于满足金融合规要求里的商密算法场景;另一个是大文件场景下的密文去重,借助哈希判断两个文件内容是否相同,相同内容只存一份密文,节省银行昂贵的存储成本;再一个是加密与对象存储的配合,把密文文件直接推到对象存储桶,元数据放在数据库,密钥留在KMS,这样存储层扩容对安全模型完全透明。
最后再说个很实在的事:从我接触过的项目看,银行这类高安全场景的研发,最稀缺的不是密码学知识,而是把密码学正确落到工程里的意识。流有没有关、状态有没有并发竞争、密钥有没有硬编码、日志有没有打明文,这些不起眼的地方,反而决定了整个方案能不能过审、能不能稳定运行。