涉密大文件传输系统设计方案

系统概述

作为四川某军工单位的技术负责人,针对政府单位涉密项目的大文件传输需求,我将设计一个基于国密算法SM4的安全文件传输系统。该系统需要满足10G级别文件传输、文件夹上传下载、服务端加密存储等核心功能,同时兼容主流浏览器和信创国产化环境。

技术选型分析

在前期调研中,我们发现现有开源解决方案存在以下问题:

  1. 百度WebUploader已停更,存在安全隐患
  2. 其他开源组件技术支持不足
  3. 缺乏完整的国密算法SM4集成方案
  4. 难以满足源代码审查要求

因此,我们决定基于现有技术栈自主开发核心组件:

  • 后端:SpringBoot + 达梦数据库
  • 前端:Vue CLI + 自主开发的上传组件
  • 加密:集成国密算法SM4

系统架构设计

前端实现 (Vue CLI)

// src/utils/sm4Encryptor.js - SM4加密工具类
import { SM4 } from 'gm-crypt'

export default class SM4Encryptor {
  constructor(key) {
    if (!key || key.length !== 16) {
      throw new Error('SM4 key must be 16 bytes')
    }
    this.sm4 = new SM4({
      mode: 'cbc', // 使用CBC模式
      iv: '0000000000000000', // 初始化向量
      padding: 'pkcs#7'
    })
    this.sm4.setKey(key, 'hex')
  }

  // 加密方法
  encrypt(data) {
    if (typeof data === 'string') {
      return this.sm4.encrypt(data, 'base64')
    }
    throw new Error('Only string encryption is supported')
  }

  // 解密方法
  decrypt(data) {
    return this.sm4.decrypt(data, 'base64', 'utf8')
  }

  // 文件分块加密
  async encryptFileChunk(chunk, progressCallback) {
    return new Promise((resolve) => {
      const reader = new FileReader()
      reader.onload = (e) => {
        const encrypted = this.sm4.encrypt(e.target.result, 'base64')
        if (progressCallback) {
          progressCallback()
        }
        resolve(encrypted)
      }
      reader.readAsBinaryString(chunk)
    })
  }
}

文件上传组件核心代码

// src/components/SecureFileUploader.vue



import SM4Encryptor from '@/utils/sm4Encryptor'
import axios from 'axios'

export default {
  name: 'SecureFileUploader',
  props: {
    uploadUrl: {
      type: String,
      required: true
    },
    chunkSize: {
      type: Number,
      default: 5 * 1024 * 1024 // 5MB分块大小
    },
    sm4Key: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      fileList: [],
      encryptor: null
    }
  },
  created() {
    this.encryptor = new SM4Encryptor(this.sm4Key)
  },
  methods: {
    triggerFileInput() {
      this.$refs.fileInput.click()
    },
    
    async handleFileChange(e) {
      const items = e.target.files
      if (!items || items.length === 0) return
      
      // 处理文件和文件夹
      for (let i = 0; i < items.length; i++) {
        const file = items[i]
        const relativePath = file.webkitRelativePath || file.name
        
        // 添加到文件列表
        const fileItem = {
          id: this.generateFileId(),
          file,
          relativePath,
          size: file.size,
          progress: 0,
          status: 'pending',
          chunks: Math.ceil(file.size / this.chunkSize)
        }
        this.fileList.push(fileItem)
        
        // 开始上传
        await this.uploadFile(fileItem)
      }
    },
    
    async uploadFile(fileItem) {
      fileItem.status = 'uploading'
      const file = fileItem.file
      const chunkSize = this.chunkSize
      const totalChunks = fileItem.chunks
      
      for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
        const start = chunkIndex * chunkSize
        const end = Math.min(start + chunkSize, file.size)
        const chunk = file.slice(start, end)
        
        try {
          // 加密文件分块
          const encryptedChunk = await this.encryptor.encryptFileChunk(chunk, () => {
            fileItem.progress = ((chunkIndex + 1) / totalChunks) * 100
          })
          
          // 上传加密后的分块
          const formData = new FormData()
          formData.append('file', new Blob([encryptedChunk]))
          formData.append('fileName', file.name)
          formData.append('relativePath', fileItem.relativePath)
          formData.append('chunkIndex', chunkIndex)
          formData.append('totalChunks', totalChunks)
          formData.append('fileId', fileItem.id)
          formData.append('fileSize', file.size)
          
          await axios.post(this.uploadUrl, formData, {
            headers: {
              'Content-Type': 'multipart/form-data'
            },
            onUploadProgress: (progressEvent) => {
              // 更新进度
              const chunkProgress = (progressEvent.loaded / progressEvent.total) * 100
              fileItem.progress = ((chunkIndex * 100 + chunkProgress) / totalChunks)
            }
          })
          
        } catch (error) {
          console.error(`上传分块 ${chunkIndex + 1}/${totalChunks} 失败:`, error)
          fileItem.status = 'error'
          return
        }
      }
      
      // 所有分块上传完成
      fileItem.status = 'success'
      this.$emit('upload-complete', fileItem)
    },
    
    generateFileId() {
      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        const r = Math.random() * 16 | 0
        const v = c === 'x' ? r : (r & 0x3 | 0x8)
        return v.toString(16)
      })
    },
    
    formatSize(bytes) {
      if (bytes === 0) return '0 Bytes'
      const k = 1024
      const sizes = ['Bytes', 'KB', 'MB', 'GB']
      const i = Math.floor(Math.log(bytes) / Math.log(k))
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
    }
  }
}

后端实现 (SpringBoot)

// 文件上传控制器
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
    
    private static final Logger logger = LoggerFactory.getLogger(FileUploadController.class);
    
    @Value("${file.upload-dir}")
    private String uploadDir;
    
    @Value("${sm4.encrypt-key}")
    private String sm4Key;
    
    private final SM4Util sm4Util;
    private final FileMetadataRepository fileMetadataRepository;
    
    public FileUploadController(SM4Util sm4Util, FileMetadataRepository fileMetadataRepository) {
        this.sm4Util = sm4Util;
        this.fileMetadataRepository = fileMetadataRepository;
    }
    
    @PostMapping("/upload")
    public ResponseEntity uploadFile(
            @RequestParam("file") MultipartFile file,
            @RequestParam("fileName") String fileName,
            @RequestParam("relativePath") String relativePath,
            @RequestParam("chunkIndex") int chunkIndex,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("fileId") String fileId,
            @RequestParam("fileSize") long fileSize) {
        
        try {
            // 创建文件存储目录
            Path uploadPath = Paths.get(uploadDir).toAbsolutePath().normalize();
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }
            
            // 处理分块文件
            String tempFileName = fileId + ".part";
            Path tempFilePath = uploadPath.resolve(tempFileName);
            
            // 解密文件分块 (服务端也需要支持SM4解密)
            byte[] decryptedBytes = sm4Util.decrypt(file.getBytes(), sm4Key);
            
            // 写入分块文件
            try (OutputStream out = Files.newOutputStream(tempFilePath, 
                    chunkIndex > 0 ? StandardOpenOption.APPEND : StandardOpenOption.CREATE)) {
                out.write(decryptedBytes);
            }
            
            // 如果是最后一个分块,合并文件
            if (chunkIndex == totalChunks - 1) {
                Path finalFilePath = uploadPath.resolve(relativePath);
                Files.createDirectories(finalFilePath.getParent());
                
                // 重命名临时文件为最终文件名
                Files.move(tempFilePath, finalFilePath, StandardCopyOption.REPLACE_EXISTING);
                
                // 保存文件元数据到达梦数据库
                FileMetadata metadata = new FileMetadata();
                metadata.setFileId(fileId);
                metadata.setFileName(fileName);
                metadata.setRelativePath(relativePath);
                metadata.setFileSize(fileSize);
                metadata.setUploadTime(LocalDateTime.now());
                metadata.setStoragePath(finalFilePath.toString());
                fileMetadataRepository.save(metadata);
                
                logger.info("文件 {} 上传完成", relativePath);
            }
            
            return ResponseEntity.ok().build();
            
        } catch (Exception e) {
            logger.error("文件上传失败: {}", e.getMessage(), e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Collections.singletonMap("error", "文件上传失败"));
        }
    }
}

SM4加密工具类 (Java)

// SM4加密工具类
@Component
public class SM4Util {
    
    public byte[] encrypt(byte[] data, String hexKey) throws Exception {
        // 验证密钥长度
        if (hexKey == null || hexKey.length() != 32) {
            throw new IllegalArgumentException("SM4 key must be 16 bytes (32 hex characters)");
        }
        
        // 创建SM4加密器 (使用CBC模式)
        SM4Engine sm4Engine = new SM4Engine();
        CBCBlockCipher cbcBlockCipher = new CBCBlockCipher(sm4Engine);
        
        // 初始化向量 (16字节的0)
        byte[] iv = new byte[16];
        ParametersWithIV parametersWithIV = new ParametersWithIV(
                new KeyParameter(Hex.decode(hexKey)), iv);
        
        // 创建缓冲加密器
        BufferedBlockCipher cipher = new PaddedBufferedBlockCipher(cbcBlockCipher, new PKCS7Padding());
        cipher.init(true, parametersWithIV);
        
        // 执行加密
        byte[] output = new byte[cipher.getOutputSize(data.length)];
        int length = cipher.processBytes(data, 0, data.length, output, 0);
        length += cipher.doFinal(output, length);
        
        // 返回实际加密后的数据
        return Arrays.copyOf(output, length);
    }
    
    public byte[] decrypt(byte[] encryptedData, String hexKey) throws Exception {
        // 验证密钥长度
        if (hexKey == null || hexKey.length() != 32) {
            throw new IllegalArgumentException("SM4 key must be 16 bytes (32 hex characters)");
        }
        
        // 创建SM4解密器 (使用CBC模式)
        SM4Engine sm4Engine = new SM4Engine();
        CBCBlockCipher cbcBlockCipher = new CBCBlockCipher(sm4Engine);
        
        // 初始化向量 (16字节的0)
        byte[] iv = new byte[16];
        ParametersWithIV parametersWithIV = new ParametersWithIV(
                new KeyParameter(Hex.decode(hexKey)), iv);
        
        // 创建缓冲解密器
        BufferedBlockCipher cipher = new PaddedBufferedBlockCipher(cbcBlockCipher, new PKCS7Padding());
        cipher.init(false, parametersWithIV);
        
        // 执行解密
        byte[] output = new byte[cipher.getOutputSize(encryptedData.length)];
        int length = cipher.processBytes(encryptedData, 0, encryptedData.length, output, 0);
        length += cipher.doFinal(output, length);
        
        // 返回实际解密后的数据
        return Arrays.copyOf(output, length);
    }
}

系统安全设计

  1. 传输安全

    • 所有数据传输均采用SM4加密
    • 分块上传机制减少单次传输数据量
    • 支持断点续传
  2. 存储安全

    • 服务端存储加密文件
    • 文件元数据存储在达梦数据库
    • 严格的访问权限控制
  3. 密钥管理

    • 使用硬件安全模块(HSM)管理主密钥
    • 每次传输使用临时会话密钥
    • 密钥交换采用SM2国密算法

信创环境兼容性

  1. 操作系统:支持麒麟、统信UOS等国产操作系统
  2. 浏览器:兼容360安全浏览器、红芯浏览器等信创浏览器
  3. 数据库:全面适配达梦数据库
  4. 中间件:支持东方通、宝兰德等国产中间件

源代码审查准备

为满足政府单位源代码审查要求,我们将:

  1. 提供完整的前后端源代码
  2. 包含详细的开发文档和注释
  3. 提供加密算法实现的白皮书
  4. 准备第三方安全审计报告
  5. 建立源代码版本管理系统

后续工作计划

  1. 完成核心功能开发并进行单元测试
  2. 进行系统集成测试和性能测试
  3. 申请国密局相关安全认证
  4. 准备源代码审查材料
  5. 部署到测试环境进行用户验收测试

该方案完全自主可控,所有核心代码均可提供源代码审查,满足政府单位对信息安全的高标准要求。

将组件复制到项目中

示例中已经包含此目录
image

引入组件

image

配置接口地址

接口地址分别对应:文件初始化,文件数据上传,文件进度,文件上传完毕,文件删除,文件夹初始化,文件夹删除,文件列表
参考:http://www.ncmem.com/doc/view.aspx?id=e1f49f3e1d4742e19135e00bd41fa3de
image

处理事件

image

启动测试

image

启动成功

image

效果

image

数据库

image

效果预览

文件上传

文件上传

文件刷新续传

支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传
文件续传

文件夹上传

支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。
文件夹上传

批量下载

支持文件批量下载
批量下载

下载续传

文件下载支持离线保存进度信息,刷新页面,关闭页面,重启系统均不会丢失进度信息。
下载续传

文件夹下载

支持下载文件夹,并保留层级结构,不打包,不占用服务器资源。
文件夹下载

下载示例

点击下载完整示例

Logo

欢迎大家加入成都城市开发者社区,“和我在成都的街头走一走”,让我们一起携手,汇聚IT技术潮流,共建社区文明生态!

更多推荐