跳到主要内容

如何加密一个超100G的大文件

1. 一些背景

最近在学响应式编程的时候,偶然在知乎上看到一个新闻,说的是有人的icloud数据丢失了,那我在想,能否把自己的icloud数据定期导出然后进行加密,备份到某些云盘当中,找着找着发现Bouncy Castle

Bouncy Castle是一个开源的加密和密码学库,旨在为Java和C#开发者提供安全的编程工具。它提供了一系列密码学算法、协议和工具,包括对称加密、非对称加密、数字签名、消息摘要、证书操作等

而BC加密的代码似乎只是单线程的,对于小文件的加密还好,如果是大文件加密的时候,就会直接内存溢出。

那么能否使用响应式编程分段加密一个大文件的内容?解密的时候则反之,对加密后的内容分段解密,按照顺序写入同一个文件即可。

2. 怎么做呢

我想到以前写netty 的时候有玩过 RandomAccessFile ,可以对一个文件分割成多个ChunkFile ,这段记忆的来源于几年前在某家公司做端对端文件传输的时候用到过这个。

那么使用RandomAccessFile把单个文件读取为多个ChunkFile,然后进行加密,加密后转base64编码,则完成了加密的步骤。

这是主要的流程,当中使用响应式编程框架project-reactor更加方便处理多个线程任务,对不同的ChunkFile 进行加密,最后按照顺序写入文件。而对字节数组进行base64编码后,会导致最后生成的文件变大1.x倍左右,所以最后可以再加个能对base64编码后的文本压缩的方法,再得到最后的文件,减少文件的体积。

wooow,想的就觉得很有意思~~(自我感觉)~~,这样以后所有的隐私文件都能这样处理,甚至上传到notion上,notion可是支持无限容量上传的,不过会限制单个文件在5G以内,这个嘛我们也是有办法解决的,写入文件的时候再做分片好了,限制单个分片文件的大小。

下面章节内容中包含的代码,你可以在我的github中找到,并直接使用CipherUtilTestencryptBigFile 方法先玩玩简单的功能,这个类包含了所有功能的unit test,可以尝试一下加密一个文件,然后又还原一个文件。

2.1 实现效果

20G文件加密:

对一个22G的文件维基百科压缩包进行加密,单个ChunkFile 为5MB

耗时:209.239秒 = 3分29秒

得到的加密内容转base64编码(未对base64编码压缩)后为30GB,膨胀了大概1.33倍

20G文件解密:

对刚刚加密出来的30GB文件进行解密,并生成新文件

耗时:158.175秒 = 2分38秒

100G文件加密

对一个113G的文件维基百科压缩包进行加密,单个ChunkFile 为5MB

耗时:964.570秒 = 16分4秒

得到的加密内容转base64编码(未对base64编码压缩)后为150.69GB,仍然膨胀了大概1.33倍

100G文件解密

对刚刚加密出来的150.69GB文件进行解密,并生成新文件

耗时:797.499秒 = 13分17秒

解密后的文件和原文件的hash256是一样的,所以解密也是没问题的

3. 先玩一玩Bouncy Castle

Bouncy Castle库提供了对许多常见的密码学算法的支持,包括AES、DES、RSA、DSA、ECDSA等。它还支持各种密码模式,如CBC、ECB、GCM等。此外,Bouncy Castle还提供了处理数字证书和密钥管理的功能,包括X.509证书的生成和验证,PKCS#12格式的读写,以及密钥生成和存储。 Bouncy Castle库的设计目标之一是提供易于使用的API,以简化密码学操作的复杂性。它提供了面向对象的接口,并且在许多方面提供了比JDK本身更高级别的抽象。这使得开发人员可以更轻松地使用密码学功能,而无需深入研究底层的复杂细节。

在这里我选择的是非对称加密的ECC算法,原因在于非对称加密的安全性更高,公钥是公开的,秘钥是自己保存的,不需要将私钥给别人。

和RSA算法对比的话,可以提供相同安全性水平下更短的密钥长度,从而减少计算和存储资源的需求。

3.1 代码部分

最新版本的Bouncy Castle Crypto库可以在mvn repository找到

https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on

引入依赖:

<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.76</version>
</dependency>

先来玩一个简单的, 该方法使用Bouncy Castle Provider提供的加密实现。它生成一个椭圆曲线密钥对(公钥和私钥),并将其保存到指定的文件路径中。然后,使用公钥初始化加密器,并使用ECIES参数对其进行配置。最后,将加密内容进行加密,并返回加密后的结果。

byte[] derivation = Hex.decode("202122232425263738393a3b3c3d3e3f");
byte[] encoding = Hex.decode("303132333435362728292a2b2c2d2e2f");

/**
* Encrypt by Elliptic Curve Crypt
*
* @param encryptContent 加密内容
* @param curveName 曲线名称 例如:
* secp256k1:这是比特币和以太坊等加密货币中广泛使用的椭圆曲线参数。它具有 256 位的长度,并提供了良好的安全性和性能。
* secp256r1/prime256v1:这是一种常见的椭圆曲线参数,也被称为 NIST P-256。它被广泛用于许多安全协议和应用中,提供了适当的安全性和性能。
* secp384r1:也称为 NIST P-384,它是一种具有 384 位长度的椭圆曲线参数,提供了比 secp256k1 和 secp256r1 更高的安全性,但可能会稍微降低性能。
* secp521r1:也称为 NIST P-521,它是一种具有 521 位长度的椭圆曲线参数,提供了最高级别的安全性,但可能会牺牲一些性能。
* @param transformation transformation : ECIES(Elliptic Curve Integrated Encryption Scheme)加密方案
* @param savePrivateKeyPath 保存私钥路径
* @param savePublicKeyPath 保存公钥路径
* @return {@link byte[]}
*/
@SneakyThrows
static byte[] encryptByECC(byte[] encryptContent,
String curveName,
String transformation,
String savePrivateKeyPath,
String savePublicKeyPath) {
Security.addProvider(new BouncyCastleProvider());

KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", "BC");
keyPairGenerator.initialize(ECNamedCurveTable.getParameterSpec(curveName));

KeyPair keyPair = keyPairGenerator.generateKeyPair();

final PrivateKey privateKey = keyPair.getPrivate();
final PublicKey publicKey = keyPair.getPublic();
savePrivateKey(privateKey, savePrivateKeyPath);
savePublicKey(publicKey, savePublicKeyPath);

// 使用公钥进行加密
IESParameterSpec params = new IESParameterSpec(derivation, encoding, 128, 128, null);
Cipher encryptCipher = Cipher.getInstance(transformation, "BC");
encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey, params);

return encryptCipher.doFinal(encryptContent);
}

/**
* 保存公钥
*
* @param publicKey 公钥
* @param filePath
*/
public static void savePublicKey(PublicKey publicKey, String filePath) {
X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(publicKey.getEncoded());
saveToFile(filePath, x509EncodedKeySpec.getEncoded());
}

/**
* 保存私钥
*
* @param privateKey
* @param filePath 文件路径
*/
public static void savePrivateKey(PrivateKey privateKey, String filePath) {
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKey.getEncoded());
saveToFile(filePath, pkcs8EncodedKeySpec.getEncoded());
}

那么怎么使用呢?

对一段字符进行加密如下

@Test
void encryptByECC() throws IOException {
String password = "p@ssW0rd";
final byte[] bytes = CipherUtil.encryptByECC(password.getBytes(),
"secp256k1",
"ECIES",
"PrivateKey.pem",
"PublicKeyPath.pem");
FileUtil.writeBytesToFile(bytes, "password.enc");
Assertions.assertNotNull(bytes);
}

上面的代码中使用椭圆曲线加密(ECC)对密码进行加密,并将加密后的结果保存到文件中。

代码逻辑如下:

  1. 定义一个密码字符串password,它将被加密。
  2. 调用CipherUtil.encryptByECC方法,传递以下参数:
  • password.getBytes():将密码字符串转换为字节数组,作为待加密的内容。
  • "secp256k1":指定椭圆曲线的名称,这里使用的是secp256k1曲线。
  • "ECIES":指定加密方案,使用ECIES(Elliptic Curve Integrated Encryption Scheme)。
  • "PrivateKey.pem":指定保存私钥的文件路径。
  • "PublicKeyPath.pem":指定保存公钥的文件路径。这样,CipherUtil.encryptByECC方法将对密码进行加密,并返回加密后的字节数组。

4. 使用project reactor+RandomAccessFile

加密方法有了之后,接下来的部分就是,读取任意文件,分割成多个块文件,分别对其转byte array进行加密,然后对加密后的byte array转base64

首先我们需要知道要读取那个文件

Mono.just(filePath)
.map(FileUtil::newRandomAccessFile).flux()

读取之后,对这个文件分成多个ChunkedFile ,这里使用了Flux.create 进行创建元素的发射

chunkSize 一般是字节,所以如果要每次读4MB大小的内容chunkSize=1024 * 1024 * 4


/**
* Split a file into multiple files of specified size
*
* @param file file
* @param chunkSize chunkSize
* @return {@link Flux}<{@link ChunkFileInfo}>
*/
static Flux<ChunkFileInfo> split2ChunkedFiles(RandomAccessFile file, int chunkSize) {
return Flux.create(emitter -> {
try {
//获取文件的FileChannel,用于读取文件内容。
FileChannel channel = file.getChannel();

//获取文件的总大小。
long fileSize = channel.size();
long currentPosition = 0;

while (currentPosition < fileSize ){

while (emitter.requestedFromDownstream() == 0 && !emitter.isCancelled()) {
// 在没有向下游请求数据且没有取消订阅的情况下,等待。
}

//计算剩余文件大小。
long remainingSize = fileSize - currentPosition;
//计算每个块的读取大小,取剩余大小和指定块大小之间的最小值。
int readSize = (int) Math.min(remainingSize, chunkSize);

//创建一个字节缓冲区,用于读取文件内容。
ByteBuffer buffer = ByteBuffer.allocate(readSize);
//从文件通道中读取数据到缓冲区。
channel.read(buffer, currentPosition);

byte[] byteArray = new byte[readSize];
//重置缓冲区的位置和限制,以便读取数据。
buffer.flip();
buffer.get(byteArray);
//发送下一个块的信息给订阅者。
emitter.next(new ChunkFileInfo(currentPosition, currentPosition + readSize, readSize, byteArray));
//更新当前位置,以继续读取下一个块。
currentPosition += readSize;
}
emitter.complete();
} catch (IOException e) {
emitter.error(e);
}
});
}

/**
* @author Asher
*/
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
class ChunkFileInfo {

private long startOffset;
private long endOffset;
private int chunkSize;

private byte[] bytes;

}

组合之前的代码,如下

Mono.just(filePath)
.map(FileUtil::newRandomAccessFile).flux()
.flatMap(file -> split2ChunkedFiles(file, chunkSize).limitRate(8))

而这里为什么后面要加.limitRate(8) 是防止flatMap订阅内部流的时候,如果没有限制,会导致程序一次性加载整个文件,导致内存溢出

接着对生成的chunkedFile 进行加密处理,并转base64编码

long startOffset = chunkedFile.getStartOffset();
long endOffset = chunkedFile.getEndOffset();
return Mono.just(chunkedFile.getBytes())
.map(chunkByte -> encryptByECC(chunkByte, "secp256k1", "ECIES", publicKey, "EC"))
.map(encryptBytes -> Base64.getEncoder().encodeToString(encryptBytes))
.map(base64 -> startOffset + ":" + endOffset + ":" + base64)
;
  • encryptByECC 则是上一个章节中使用的加密方法
  • 最后生成的文本包含了这段chunkedFile的startOffset和endOffset,因为我们还原文件的时候需要用到,要在同样的位置写入。

4.1 完整代码如下

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.IESParameterSpec;
import org.bouncycastle.util.encoders.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.runnable.commontool.entity.ChunkFileInfo;

import javax.crypto.Cipher;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

static Flux<Void> encryptBigFile(String filePath, String targetFilePath, int chunkSize, File publicKey){
return Mono.just(filePath)
.doFirst(deleteFile(targetFilePath))
.map(FileUtil::newRandomAccessFile).flux()
.flatMap(file -> split2ChunkedFiles(file, chunkSize).limitRate(8))
.doOnNext(it -> log.info("startOffset:{} endOffset:{}", it.getStartOffset(), it.getEndOffset()))
.flatMapSequential(chunkedFile -> {
long startOffset = chunkedFile.getStartOffset();
long endOffset = chunkedFile.getEndOffset();
return Mono.just(chunkedFile.getBytes())
.publishOn(Schedulers.boundedElastic())
.flux()
.doOnNext(it -> log.info("starting encrypt chunk file"))
.map(chunkByte -> encryptByECC(chunkByte, "secp256k1", "ECIES", publicKey, "EC"))
.map(encryptBytes -> Base64.getEncoder().encodeToString(encryptBytes))
.map(base64 -> startOffset + ":" + endOffset + ":" + base64)
;
})
.concatMap(content -> appendToFile(targetFilePath, content));
}

@SneakyThrows
static RandomAccessFile newRandomAccessFile(String path) {
return new RandomAccessFile(path, "r");
}

/**
* Split a file into multiple files of specified size
*
* @param file file
* @param chunkSize chunkSize
* @return {@link Flux}<{@link ChunkFileInfo}>
*/
static Flux<ChunkFileInfo> split2ChunkedFiles(RandomAccessFile file, int chunkSize) {
return Flux.create(emitter -> {
try {
FileChannel channel = file.getChannel();
long fileSize = channel.size();
long currentPosition = 0;

while (currentPosition < fileSize ){

while (emitter.requestedFromDownstream() == 0 && !emitter.isCancelled()) {
//waiting request
}

long remainingSize = fileSize - currentPosition;
int readSize = (int) Math.min(remainingSize, chunkSize);

ByteBuffer buffer = ByteBuffer.allocate(readSize);
channel.read(buffer, currentPosition);

byte[] byteArray = new byte[readSize];
buffer.flip();
buffer.get(byteArray);
emitter.next(new ChunkFileInfo(currentPosition, currentPosition + readSize, readSize, byteArray));
currentPosition += readSize;
}
emitter.complete();
} catch (IOException e) {
emitter.error(e);
}
});
}

5. 加密文件还原

文件加密后,我们可以通过逆向的过程进行还原:读取文本内容→base64解码→解密byte array→写入新文件

因为我们使用的响应式编程,所以读取文本内容,我们可以这样操作

static Flux<String> readLines(String path){
return Flux.using(
() -> Files.lines(Path.of(path)),
Flux::fromStream,
BaseStream::close
);
}

使用Flux.using 操作文本的读取,会让代码看上去更加简洁,流式调用(fluent)风格+避免了副作用(side-effect)

base64解码 就不用说了,只是一行代码的事,解密byte array,已经封装好了对应的方法如下

/**
* 通过 Elliptic Curve Crypt 解密
*
* @param decryptContent 需要解密内容
* @param privateKey 私钥
* @param transformation 转型
* @return {@link byte[]}
*/
@SneakyThrows
static byte[] decryptByEllipticCurveCrypt(byte[] decryptContent,
PrivateKey privateKey,
String transformation){
// 使用私钥进行解密
Cipher decryptCipher = Cipher.getInstance(transformation, "BC");
IESParameterSpec params = new IESParameterSpec(derivation, encoding, 128, 128, null);
decryptCipher.init(Cipher.DECRYPT_MODE, privateKey, params);

return decryptCipher.doFinal(decryptContent);
}

把需要解密的decryptContent传入,还有私钥文件,以及加密时的转型,最后返回的时候也是得到解密后的byte array

5.1 完整代码如下:

/**
* Decrypt the file encrypted by the encryptBigFile method and
* restore it to the same file
*
* @param encryptFilePath encryptFilePath
* @param targetFilePath targetFilePath
* @param privateKey privateKey
* @return {@link Flux}<{@link Void}>
*/
static Flux<Void> decryptBigFile(String encryptFilePath, String targetFilePath, File privateKey){
return Mono.just(encryptFilePath)
.flux()
.doFirst(deleteFile(targetFilePath))
.flatMap(it -> FileUtil.readLines(it).limitRate(8))
.buffer(4)
.flatMapSequential(lines ->
Flux.fromIterable(lines)
.publishOn(Schedulers.boundedElastic())
.map(line -> decrypt2ChunkFileInfo(privateKey, line))
)
.doOnNext(it -> log.info("startOffset:{} endOffset:{}", it.getStartOffset(), it.getEndOffset()))
.publishOn(Schedulers.single())
.concatMap(chunkFileInfo -> {
return Mono.just(chunkFileInfo)
.doOnNext(it -> mergeChunkFile(targetFilePath, it))
.then();
});
}

/**
* Read the contents of a file line by line, supporting backpressure
*
* @param path 路径
* @return {@link Flux}<{@link String}>
*/
static Flux<String> readLines(String path){
return Flux.using(
() -> Files.lines(Path.of(path)),
Flux::fromStream,
BaseStream::close
);
}

private static ChunkFileInfo decrypt2ChunkFileInfo(File privateKeyFile, String line) {
String[] split = line.split(":");
long startOffset = Long.parseLong(split[0]);
long endOffset = Long.parseLong(split[1]);
String base64Str = split[2];
log.info("starting decode");
byte[] decode = Base64.getDecoder().decode(base64Str);
byte[] decryptByte = CipherUtil.decryptByEllipticCurveCrypt(decode, privateKeyFile, "EC", "ECIES");

return new ChunkFileInfo(startOffset, endOffset, (int)(endOffset - startOffset), decryptByte);
}

@SneakyThrows
private static void mergeChunkFile(String decryptFilePath, ChunkFileInfo chunkFileInfo) {
log.info("starting writeChunkFile");
// Open file for append using "rw"
try (RandomAccessFile mergedFile = new RandomAccessFile(decryptFilePath, "rw")){
// Move the file pointer to the starting position
mergedFile.seek(chunkFileInfo.getStartOffset());
mergedFile.write(chunkFileInfo.getBytes(), 0, chunkFileInfo.getChunkSize());
}
}

private static Runnable deleteFile(String filePath) {
return () -> {
try {
File file = new File(filePath);
if (file.exists()) {
FileUtils.forceDelete(file);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
};
}

6. 参考内容

对称加密算法与非对称加密算法的优缺点

Reactor技巧4则