拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Vue大文件上传国密加密实战:SM4分片加密与断点续传完整方案

第一次把大文件上传和国密加密放到同一个方案里时我心里其实有点打鼓。单纯做上传分片、断点续传、秒传那套东西我已经很熟了单纯做加密SM2、SM3、SM4的用法也不算陌生。真正难的是把它们串起来前端Vue这边既要处理几个G的大文件又不能因为加密把页面搞卡死服务端落盘之后还得保证数据是密文下载时又能按规矩解开。这套链路任何一个环节掉链子项目就推不下去。这篇文章就是我落地“Vue大文件上传 加密存储 国密算法应用”的完整记录包含方案选型、核心代码、服务端关键接口以及我踩过的坑。适合正在做私有化部署、有等保或密评要求、需要处理大文件上传的前后端工程师参考。内容偏工程实践我会把为什么这么设计也讲清楚免得大家照抄了代码还不知道哪里容易翻车。1. 整体设计思路先想清楚数据从哪一步开始加密1.1 为什么不是“整个文件加密后上传”我第一次设计这个功能时第一反应是前端用国密把整个文件加密成一个新文件然后当作普通大文件上传服务端直接存密文简单粗暴。后来在草稿纸上算了笔账立刻放弃。一个4GB的文件如果整体加密后再上传浏览器里要一次性把数据读进内存再跑一遍SM4运算。Node.js或服务端跑SM4能到几百MB每秒但浏览器里的纯JavaScript实现性能差很多sm-crypto这类库的SM4大概也就几十MB每秒还不算数据从ArrayBuffer转到字节数组的复制开销。等加密完浏览器大概率已经白屏或者崩溃了。更关键的是网络问题。大文件上传最怕的是传了半天突然断网整体加密后只能从头再来。分片之后每一片独立加密、独立上传、独立校验断点续传的粒度就变成了“分片”重传成本只有几MB完全可接受。所以方案定下来了前端把大文件按固定大小切成多个分片每个分片独立用SM4加密分片加密在Web Worker里做不占主线程加密后的分片按顺序上传服务端按顺序合并成密文文件文件指纹用SM3相关方案做支持秒传和断点续传这套设计里加密和上传是解耦的加密是计算密集型任务交给Worker上传是IO密集型任务做并发控制。两边通过消息通信就像流水线一样密文块生成一个传一个。1.2 国密三件套在方案里各司其职既然客户点名要国密方案里用的就是SM2、SM3、SM4这三个算法。很多人在国密应用上有个误区以为“用SM4加密文件”就算完成国密合规了。实际上标准做法是让三个算法各干各的活我这里的分工如下算法在方案中的角色原因SM4分片内容加密对称加密加解密速度快适合大文件数据体的加解密性能远好于非对称算法SM2加密SM4工作密钥非对称加密私钥只有服务端持有实现“前端不可解密”SM3文件摘要、分片完整性校验哈希算法用来算文件指纹和验证密文是否被篡改换句话说SM4负责“锁住数据”SM2负责“传递钥匙”SM3负责“检查锁有没有被撬过”。三个算法配合才算完整的国密应用而不是单单拿SM4跑一遍。1.3 密钥管理的边界前端不应当持有解密能力加密存储方案里密钥管理比算法本身更容易出问题。我的设计原则是前端每次上传生成一把随机的SM4工作密钥数据加密用这把工作密钥前端只有SM2公钥用公钥加密工作密钥后传给服务端服务端用SM2私钥解出工作密钥再用服务端的主密钥也走SM4加密后落库存放。这把工作密钥是“一次性”的一个上传任务对应一把任务完成或失败就作废。这样即使某个文件的密文泄露了攻击者拿到的也就是这一把工作密钥无法解开其他文件。而服务端主密钥只在配置中心或KMS里存在我自己的示例里是放在环境变量里的生产环境更严格的做法是接入硬件密码机或云KMS。前端这边的逻辑就是随机生成16字节SM4工作密钥用SM2公钥加密工作密钥在初始化上传请求里把加密后的工作密钥发给服务端之后所有分片都用这把工作密钥做SM4加密整个过程前端接触不到SM2私钥也接触不到服务端主密钥能做到“能加密、不能解密”。“加密存储”的本质是服务端落盘时只有密文而不是依赖网络链路来保证数据安全。2. 核心细节拆解文件指纹、Worker与断点续传2.1 文件指纹同一个hash的三种用法大文件上传要支持秒传和断点续传前提是能识别“这是同一个文件”。很多初学者直接拿文件名加文件大小当标识这样做会遇到一个很尴尬的情况同名不同文件会被误判改个名又会被重复上传。正确做法是给整个文件计算一个内容指纹。我这里的指纹计算方式是先用spark-md5按2MB分块增量读取文件算出全文件的MD5再对这段MD5字符串做一次SM3得到最终的fileDigest。有人可能会问既然要求国密为什么不直接全文件算SM3原因很现实spark-md5在浏览器里成熟稳定能配合FileReader做流式增量计算几GB的文件也不会撑爆内存而sm-crypto的SM3增量实现不够通用自己往外写又容易出边界问题。我把MD5结果再做一次SM3是为了让最终用于文件标识的摘要也是国密算法的结果在合规审查和功能上两头都能交代。服务端拿到fileDigest后会做两件事判断文件是否存在存在就直接返回“秒传成功”查询已上传的分片索引返回给前端实现断点续传文件指纹不承担机密性保护职责它只负责唯一性和完整性标识。真正做密文完整性校验的是每个分片的SM3摘要这一点后面代码里会体现。2.2 Web Worker不是可选项是性能账本算出来的必选项我第一次把SM4加密直接写在主线程里测试上传一个2GB文件点击选择文件后页面瞬间卡住滚动条拖不动进度条也不刷新。原因很简单JavaScript是单线程的加密循环占用主线程DOM更新全部被阻塞。解决方案就是Web Worker。加密任务全部放进Worker主线程只负责调度和发请求。还有个细节Worker和主线程之间传递数据默认是拷贝的一个5MB的分片加密结果动不动就要复制一份内存开销很大。解决办法是用Transferable Objects把ArrayBuffer的所有权转移给主线程而不是复制。在代码里就是postMessage的第三个参数传入transfer数组。性能实测下来我是拿一台普通笔记本跑的大家机器配置不同会有差异5MB分片在Worker里SM4-CBC加密大约需要几百毫秒到1秒2GB文件共约400多个分片纯加密阶段需要几十秒但主线程始终是流畅的进度条能实时更新这个体验上的差距比任何优化技巧都明显。2.3 分片状态机与并发控制大文件上传的前端状态管理我习惯把每个分片看作一个状态机节点每个分片有五个状态waiting、encrypting、uploading、success、failed。初始化时所有分片都是waitingWorker加密完成后进入uploading服务端确认接收后标记为success请求异常且重试耗尽就标记为failed。并发数我一般控制在3个左右。并发太小带宽利用不充分特别是上行带宽大的网络很浪费并发太大又容易触发服务端连接数限制而且失败重试的逻辑会变得很复杂。3个并发是我在多数项目里实测比较稳的值。断点续传的流程是前端算出fileDigest调用check接口服务端返回已上传的分片索引列表前端跳过这些索引只把剩余分片加入队列所有分片上传完成后调用merge接口触发服务端合并这里有一个容易踩坑的点已上传分片不能只看索引存在还要校验分片内容是否完整。后面常见问题部分我会专门讲这个。3. 实操实现Vue3 TypeScript手写加密上传链路3.1 依赖准备我用的技术栈是Vue3 Vite TypeScript加密相关依赖只装了两个npm install sm-crypto spark-md5sm-crypto是国密算法的JavaScript实现支持SM2、SM3、SM4spark-md5用于流式计算文件MD5。组件里主要用组合式API代码结构分为三个部分chunk-worker.tsWorker线程负责全文件指纹计算、分片加密useUploader.ts主线程逻辑负责任务调度、并发控制、状态管理Uploader.vueUI组件暴露选择文件和上传按钮3.2 Worker线程指纹计算与分片加密Worker的代码是整个方案的核心。初始化时接收文件和SM4工作密钥算完文件指纹后通知主线程之后主线程按需求取分片Worker负责加密并返回密文。/// reference libwebworker / import SparkMD5 from spark-md5; import { sm3, sm4 } from sm-crypto; const CHUNK_SIZE 5 * 1024 * 1024; // 5MB 分片 let file: File | null null; let workKey: Uint8Array | null null; self.onmessage async (e: MessageEvent) { const { type, payload } e.data; if (type init) { file payload.file; workKey Uint8Array.from(payload.workKey); const fileDigest await computeFileDigest(file); self.postMessage({ type: digest, payload: { fileDigest } }); } if (type encryptChunk file workKey) { const { index } payload; const start index * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); const { cipher, iv } await encryptChunk(blob, workKey); self.postMessage( { type: chunkReady, payload: { index, cipher, iv } }, { transfer: [cipher.buffer, iv.buffer] } ); } }; function computeFileDigest(f: File): Promisestring { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); const READ_SIZE 2 * 1024 * 1024; let offset 0; const readNext () { const blob f.slice(offset, Math.min(f.size, offset READ_SIZE)); if (blob.size 0) { // 全文件 MD5 算完后再做一次 SM3得到国密摘要用于文件标识 resolve(sm3(spark.end())); return; } reader.onload (ev) { spark.append(ev.target!.result as ArrayBuffer); offset blob.size; readNext(); }; reader.onerror reject; reader.readAsArrayBuffer(blob); }; readNext(); }); } async function encryptChunk(blob: Blob, key: Uint8Array) { const buf await blob.arrayBuffer(); const plain new Uint8Array(buf); const iv crypto.getRandomValues(new Uint8Array(16)); // sm-crypto 的 SM4 输入输出是字节数组这里把 Uint8Array 转成普通数组 const cipher sm4.encrypt(Array.from(plain), Array.from(key), { mode: cbc, iv: Array.from(iv), padding: pkcs7, }) as number[]; return { cipher: Uint8Array.from(cipher), iv, }; }我特别说明两个细节。第一个分片大小选5MB是权衡过的太小会导致请求数量暴增比如1GB文件切1MB就是1024个请求服务端压力大太大会让单次加密和上传耗时过长断点续传的粒度变粗。5MB是一个在请求数量、重传成本、加密耗时之间比较平衡的值。第二个每片都生成新的随机IV相同内容的两个分片加密后密文不同避免通过密文对比推断原始内容。PKCS7填充是SM4-CBC的标准做法最后一片不满16字节时会自动补齐。3.3 主线程调度useUploader组合式API主线程不直接碰文件内容它的职责是调度。我把状态变量和调度逻辑封装成一个组合式函数import { ref } from vue; import { sm2 } from sm-crypto; const CHUNK_SIZE 5 * 1024 * 1024; const CONCURRENCY 3; export function useUploader() { const progress ref(0); const status refidle | hashing | uploading | merging | done | error(idle); let file: File; let totalChunks 0; let uploadId ; let worker: Worker; let activeCount 0; const uploadedIndexes new Setnumber(); const retryCount new Mapnumber, number(); const sm2PublicKey 从服务端配置下发的SM2公钥; async function upload(f: File) { file f; totalChunks Math.ceil(f.size / CHUNK_SIZE); status.value hashing; // 每次上传生成一次性SM4工作密钥 const workKey crypto.getRandomValues(new Uint8Array(16)); const encryptedWorkKey sm2.doEncrypt(Array.from(workKey), sm2PublicKey, 1) as string; // 初始化上传任务服务端解密工作密钥并保存 const initRes await apiInit({ fileName: f.name, fileSize: f.size, totalChunks, encryptedWorkKey, }); uploadId initRes.uploadId; // 启动Worker计算文件指纹并加密分片 worker new Worker(new URL(./chunk-worker.ts, import.meta.url), { type: module }); worker.postMessage({ type: init, payload: { file: f, workKey: Array.from(workKey) } }); worker.onmessage async (e) { const { type, payload } e.data; if (type digest) { // 秒传 / 断点续传查询 const check await apiCheck({ uploadId, fileDigest: payload.fileDigest }); if (check.exists) { status.value done; progress.value 100; return; } check.uploadedIndexes.forEach((i: number) uploadedIndexes.add(i)); status.value uploading; return; } if (type chunkReady) { if (uploadedIndexes.has(payload.index)) return; scheduleUpload(payload); } }; } async function scheduleUpload(payload: { index: number; cipher: Uint8Array; iv: Uint8Array }) { if (activeCount CONCURRENCY) return; activeCount; try { await apiUploadChunk({ uploadId, index: payload.index, cipher: payload.cipher, iv: payload.iv, }); uploadedIndexes.add(payload.index); const uploaded [...uploadedIndexes].filter((i) i totalChunks).length; progress.value Math.round((uploaded / totalChunks) * 100); } catch (err) { const retry retryCount.get(payload.index) || 0; if (retry 3) { retryCount.set(payload.index, retry 1); setTimeout(() scheduleUpload(payload), 1000 * 2 ** retry); } else { status.value error; console.error(分片${payload.index}上传失败, err); } } finally { activeCount--; pumpQueue(); } } async function pumpQueue() { if (status.value uploading uploadedIndexes.size totalChunks) { status.value merging; await apiMerge({ uploadId }); status.value done; progress.value 100; } } return { progress, status, upload }; }这个代码我做了简化把一些边界处理抽掉了但核心链路是完整的。这里有几个设计点值得说重试采用指数退避第一次重试等1秒第二次等2秒第三次等4秒避免服务端刚出问题时机就被重试请求打爆进度计算用的是成功分片数除以总分片数没有把加密耗时算进去用户看到的效果是从0%稳步走到100%分片到达时先判断是否已经上传过这个判断在断点续传场景下特别重要能防止重复上传3.4 Vue组件简单封装但不简单处理Uploader.vue只做两件事选择文件、调用useUploader。UI层保持薄逻辑都在组合式函数里方便以后接入到更大表单里。template div classuploader input typefile changeonFileChange / button :disabledstatus ! idle clickstartUpload开始上传/button div classprogress div classprogress-bar :style{ width: progress % }/div /div div classstatus当前状态{{ status }}进度{{ progress }}%/div /div /template script setup langts import { ref } from vue; import { useUploader } from ./useUploader; const { progress, status, upload } useUploader(); const selectedFile refFile | null(null); function onFileChange(e: Event) { const input e.target as HTMLInputElement; selectedFile.value input.files?.[0] || null; } async function startUpload() { if (selectedFile.value) { await upload(selectedFile.value); } } /script还有一件小事大文件选择阶段建议给input加上accept限制并在选择后校验文件大小和类型不然用户选了一个超大文件前端要算很久指纹才发现不符合要求体验很糟糕。3.5 服务端关键实现Spring Boot视角前端做得再漂亮服务端接不住也是白搭。我以Spring Boot为例把几个关键接口的处理要点列出来完整代码就不贴了重点是讲清楚每步要做什么。第一个是初始化接口。拿到前端传来的encryptedWorkKey后用SM2私钥解密得到工作密钥再用配置里的主密钥SM4加密存到数据库或Redis里。这里有个容易忽略的点encryptedWorkKey在前端SM2加密时用的是C1C3C2模式还是C1C2C3模式要和后端对齐国密规范里这两种模式都存在很多联调坑都出在这里。第二个是分片上传接口。除了保存密文分片必须同时校验分片的SM3摘要。前端的加密结果里可以再带一个分片密文的SM3值服务端存之前先算一遍比对不一致就拒绝。这一步是防止网络传输过程中密文被截断或篡改也是我后面要讲的“断点续传分片损坏”问题的核心防线。第三个是合并接口。收到合并请求后先检查分片数量是否等于totalChunks再按分片索引顺序拼接密文。为了并行写入效率我用过两种方案简单方案是等所有分片收齐后按顺序读取拼接性能更好的方案是用RandomAccessFile按分片索引写入对应偏移量每个分片到达就可以写盘合并时只需要检查完整性。两种方案都能用分片数特别多时建议用后者能省一次全量读写的耗时。合并完成后最终文件是密文。文件元数据里存的是SM2加密过的SM4工作密钥、分片数量、每个分片的IV、文件SM3摘要。这样下载时才能按逆序解出明文同时也满足“数据落盘全密文”的合规要求。4. 常见问题与排查技巧实录4.1 大文件加密时页面卡死这是最早遇到的坑。原因前面讲过加密计算在主线程跑JS单线程被长任务占用UI自然卡死。排查方式很简单打开浏览器DevTools的Performance面板录制一段操作能看到主线程上有大段的黄色Task时间可能长达数秒。修复就是把加密逻辑整体搬进Worker同时注意postMessage时用transfer把ArrayBuffer转移出去减少主线程的序列化压力。4.2 断点续传后合并出的文件损坏这个问题很隐蔽。第一次实现断点续传时我用“分片索引存在”来判断是否需要重新上传结果是文件合并后有时候能打开有时候损坏。后面排查发现某个分片之前上传时网络中断服务端只写入了半个分片就返回了索引却被记录了。重传时前端看到索引已存在就跳过导致合并时数据不完整。修法是在check接口返回已上传分片时同时返回每个分片的长度和SM3摘要。前端对比本地分片的长度和摘要不完全一致就重新上传。这个校验逻辑写起来不复杂但能救命。4.3 并发上传后分片顺序乱掉并发上传时分片到达服务端的顺序是乱的如果合并时按“收到顺序”拼接文件必然损坏。我见过有人把所有分片收到后直接按文件名排序如果分片文件命名是1.enc、10.enc这种字符串排序会把10排在2前面又是个隐蔽坑。正确做法是用数字索引排序或者更稳妥地按分片索引写入偏移量。我还建议分片文件名统一用0.enc、1.enc这种带前导零的格式至少字符串排序不会出问题但这只能算辅助手段不能替代数字排序。4.4 SM4加密后数据变长导致的尺寸对不齐SM4是分组加密算法分组大小16字节CBC模式下最后一个分片不足16字节时要补齐。前端每个分片独立加密意味着最后一个分片可能多了几个字节的Padding。服务端合并时如果按照原始文件大小去截断会发现密文总长度比预期大。这个问题的处理要一致前端在初始化请求里上报原始文件大小合并接口按分片数量拼接密文不要把“原始文件大小”直接当作密文长度来用。解密时最后一个分片去Padding其他分片直接解。前端负责密文格式正确服务端负责原样拼接不要中间自作主张截断。4.5 秒传误判文件内容不同但指纹相同理论上不同文件算出相同SM3摘要的概率极低但工程上误判通常来自实现Bug。最常见的是spark-md5在计算时分块读取的offset计算错误或者File.slice在Safari下的兼容性问题。我调试时踩到过一次在某个低版本Safari里File.slice的end参数不传时会传undefined导致读取了整段文件。排查办法是对比浏览器里的指纹和服务端独立计算的指纹如果一致再手工检查一个已知小文件的摘要是否跟其他工具算出来一致。建议在开发阶段保留一个“计算详情”日志能看到读了多少个块、每块多大。4.6 常见问题速查表现象可能原因排查方法页面卡顿无响应SM4加密在主线程执行Performance面板看长Task把加密移动到Worker合并后文件打不开分片顺序错误 或 分片不完整检查分片排序逻辑核对分片长度与SM3摘要断点续传后分片重复或缺失已上传分片只按索引判断check接口返回分片摘要前端对比后决定是否跳过上传成功但解密失败工作密钥未解密 或 IV与分片不匹配核对每片IV是否随密文一起保存秒传误判文件指纹计算逻辑Bug用已知小文件交叉验证spark-md5和SM3结果国密加密后密文比预期长CBC模式PKCS7补齐导致服务端按分片数量拼接不解密不截断5. 几个我踩过坑之后的最终建议整套方案做完之后我最大的感触是加密存储方案里密钥管理比算法选型更考验工程能力。算法是标准化的大家写的代码都差不多但密钥怎么生成、怎么传递、怎么存储、怎么轮换才是决定系统安全性的关键。前端只能做到“密文上传”根密钥必须留在服务端或密码机里这个边界一定要守住。再分享一个小技巧调试国密加密链路时不要直接用大文件。先准备一个几百KB的测试文件把加密结果输出成十六进制字符串保存一份。然后在服务端用独立工具解密对比原始数据是否一致。链路通了再上大文件测试性能和并发。这样做的好处是出问题时你手里有一份最小可复现样本排查效率会高很多。如果你后续要扩展这个方案我建议优先考虑下载解密链路也就是把“分片加密上传”的逻辑反过来做“分片解密下载”。前端请求文件元数据拿到SM2私钥解密后的工作密钥当然这里要设计好权限控制不能让普通用户随便拿到密钥再按分片拉取密文并逐段解密。整个方案的加密存储闭环才算真正完整。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门