基于非安全文件系统的敏感数据加密存储方案

黄鹏宇 93 2023-12-31

一、背景

现在的敏感文件存在minio系统中,但该系统可能存在未知漏洞,导致文件并不安全。
本方案默认文件系统为公开访问的前提下,保证敏感文件不发生泄露。

最终目标

1. 直接访问minio链接不可读

https://file.sdtlbco.com/chinapost/OTHERS/172b5b91b8ea4a22be30c60d4a3e3390.png.encrypt

2. 前端访问系统接口

http://downloadEncrypt/token=xxx&url=https://file.sdtlbco.com/chinapost/OTHERS/xxxx.png.encrypt
转二进制流获取图片
image-1704009486261
image-1704009498130

3. 区分敏感文件和非敏感文件

对于非敏感文件不受影响,无须额外操作。

二、方案详情

4332ed7e8ccc46742f18b5ad8277cb4

1. 存量数据加密更新

  1. 从数据库中,导出敏感文件url列表
  2. 批量下载至本机
  3. 本地将文件采取AES加密
  4. 上传至minio,并添加.encrypt后缀名,区分敏感与非敏感文件
  5. 更新数据库url
  6. 删除原有文件

2. 新增数据上传

  1. 对于敏感文件,采用/upload_encrypt接口上传
  2. 服务端接收后,对文件进行AES加密,再传至minio

3. 下载

  1. 前端区分文件名是否包含.encrypt,若包含,则通过敏感文件下载接口下载
  2. 敏感文件下载接口:业务系统鉴权后,从minio下载至服务端,解密后,再传至前端

三、实施

存量数据迁移

1. 通过mysql导出文件url列表

SELECT ITEM_VALUE FROM `tbl_promotion_apply_item` where TAG_TYPE = "upload" and item_value like "https://file.sdtlbco.com%"

2. 切割item_value与多线程下载

由于item_value可能由多个url组成,如url1,url2,url3,所以使用python进行切割并下载至本机

import urllib.request
import threadpool
import os
from alive_progress import alive_bar

rawFolder = "./raw/"
ThreadNum = 16

def download(url):
    filename = url.split("/")[-1]
    # 判断是否已经下载过
    if(os.path.exists(rawFolder+filename)):
        return
    urllib.request.urlretrieve(url, rawFolder+filename)

def callBack(r,m):
    print(r)
    bar()

if __name__ == '__main__':
    threadPool = threadpool.ThreadPool(ThreadNum)
    downloadList = []

    with open("raw_url_list.txt") as raw_url_list:
        for urlStr in raw_url_list:
            urlStr = urlStr.strip()
            if urlStr == "":
                continue
            urlList = urlStr.split(",")
            for url in urlList:
                downloadList.append(url)
                
        with alive_bar(len(downloadList), force_tty=True) as bar:
            rs = threadpool.makeRequests(download, downloadList,callback=callBack) 
            for req in rs:
                threadPool.putRequest(req)
            threadPool.wait()

3. 对本地文件进行加密,重命名

秘钥存配置文件,配置文件加密参考这篇文章
https://blog.uuorb.com/archives/yamlencry

public class SecurityUtil {
    // 固定密钥,在生产中通过配置文件获取
    private static final String BASE64_SECRET = "";

    /**
     * 加密解密的byte[]
     */
    private final static byte[] SECRET_BYTES = Base64.decode(BASE64_SECRET);

    /**
     * 根据秘钥得到aes对象
     */
    private final static AES aes = SecureUtil.aes(SECRET_BYTES);
    
    public static byte[] encrypt2Byte(InputStream inputStream) {
        byte[] out = aes.encrypt(inputStream);
        return out;
    }    
}

4. 上传minio

5. 修改数据库链接

6. 验证

2. 业务系统更新

前端

  1. 对于所有需要预览图片的前端组件,根据后缀名进行判断,若为加密链接,则通过解密接口转码预览。

服务端

  1. 上传接口
    @ApiOperation("加密上传单个文件")
    @PostMapping("uploadEncrypt")
    public AjaxResult uploadFileEncrypt(MultipartFile file) {
        try {
            // 对文件进行加密
            MinioFile minioFile = minIoUtil.uploadEncrypt(file, FileType.OTHERS);
            AjaxResult ajax = AjaxResult.success();
            ajax.put("url", minioFile.getPreviewUrl());
            ajax.put("fileName", minioFile.getFileName());
            ajax.put("newFileName", minioFile.getFileName());
            ajax.put("originalFilename", file.getOriginalFilename());
            return ajax;
        } catch (Exception e) {
            return AjaxResult.error(e.getMessage());
        }
    }
  1. 加密并上传
    public MinioFile uploadEncrypt(MultipartFile file, FileType fileType) {
        String fileName = file.getOriginalFilename();
        String suffix = FileNameUtil.extName(fileName);
        String newFileName = IdUtil.fastSimpleUUID();
        String objectName = fileType.name() + "/" + newFileName + "." + suffix + ".encrypt";
        String url = endPoint + "/" + bucketName + "/" + objectName;
        InputStream inputStream = file.getInputStream();
        byte[] encryptData = SecurityUtil.encrypt2Byte(inputStream);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(encryptData);

        // 对文件进行加密
        PutObjectArgs objectArgs = PutObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .stream(byteArrayInputStream, encryptData.length, -1)
                .contentType("application/octet-stream")
                .build();
        minioClient.putObject(objectArgs);
        return MinioFile.builder()
                .previewUrl(url)
                .fileUrl(url)
                .fileName(newFileName)
                .build();
    }
  1. 根据url,解密下载
    @ApiOperation("访问加密的文件")
    @GetMapping("downloadEncrypt")
    @Anonymous
    public void downloadEncrypt(@RequestParam("fileUrl") String fileUrl, @RequestParam("token") String token, HttpServletResponse response) {
        boolean b = tokenService.verifyToken(token);
        if (!b) {
            return;
        }
        try {
            byte[] bytes = HttpUtil.downloadBytes(fileUrl);
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            byte[] decryptedBytes = SecurityUtil.decrypt(byteArrayInputStream);
            response.setContentType(MediaType.IMAGE_PNG_VALUE);
            // 将解密后的字节数组写入响应输出流
            OutputStream outputStream = response.getOutputStream();
            outputStream.write(decryptedBytes);
            outputStream.flush();
            outputStream.close();
        } catch (Exception e) {
            log.error("下载文件失败", e);
        }
    }
  1. 不使用token过滤器,自定义判断token参数
    public boolean verifyToken(String token) {
        // 获取请求携带的令牌
        if (StringUtil.isNotEmpty(token)) {
            try {
                Claims claims = parseToken(token);
                return claims != null;
            } catch (Exception ignored) {
                return false;
            }
        }
        return false;
    }

五、影响与风险

1. 影响

  1. 大文件的加解密性能问题
  2. 文件经过两次下载的宽带损耗:minio到服务端,服务端到前端

2. 风险

存量文件的误删,漏处理问题