说明

项目地址:musnows/encrypt2bdy

之前写的一篇博客提到了我的encrypt2bdy项目中,文件直接整个被读取到内存里面再计算md5,导致内存占用巨大的问题。后续解决方法是用分片的方式挨个读取文件块并计算md5。

但光是修改md5计算方式还不够,因为项目中还涉及到上传加密后文件的问题,且听我细细道来👇

关于文件加密库

cryptography.fernet

我的加密解密操作使用的是Fernet这个库,它会创建一个密钥文件,并借密钥文件对字节流进行加密和解密操作。

1
from cryptography.fernet import Fernet

但是,Fernet框架不适合进行分片加密和解密。在我的尝试中,分片加密文件是可以的,但是解密的时候会因为密钥对不上而出现异常。

1
cryptography.fernet.InvalidToken – If the token is in any way invalid, this exception is raised. A token may be invalid for a number of reasons: it is older than the ttl, it is malformed, or it does not have a valid signature.

官方文档里面提到了,Fernet只适合用于能完全加载到内存里面的数据,不适合用于处理大文件。

Limitations:
Fernet is ideal for encrypting data that easily fits in memory. As a design feature it does not expose unauthenticated bytes. This means that the complete message contents must be available in memory, making Fernet generally unsuitable for very large files at this time.

在当前备份文件的这个场景下,将文件全部加载到内存里面是不合理的。所以需要换一个加密/解密库

pyAesCrypt

改用pyAesCrypt了,加密和解密的处理非常简单,也可以分片加载。如下代码示例中,input_file是待加密文件,output_file是加密后文件,password是用户提供的加密密钥。

1
2
3
4
5
6
7
8
9
import pyAesCrypt

def encrypt_file(input_file, output_file, password, buffer_size=64 * 1024):
with open(input_file, 'rb') as file_in, open(output_file, 'wb') as file_out:
pyAesCrypt.encryptStream(file_in, file_out, password, buffer_size)

def decrypt_file(input_file, output_file, password, buffer_size=64 * 1024):
with open(input_file, 'rb') as file_in, open(output_file, 'wb') as file_out:
pyAesCrypt.decryptStream(file_in, file_out, password, buffer_size)

只要用户还记得住自己的加密密钥,那么他就可以用解密函数将文件解密出来。

AES加密是当前广泛使用的对称加密方式,至于它是怎么实现的,能否被破解都是密码学的范畴了。我了解到的信息是,破解AES加密算法本身的消耗巨大,真要去破解,一般都会采用猜测密钥的方式(即猜测你是用什么密钥加密的这个文件)

所以当你需要使用AES密钥来加密文件的时候,一定要选用一个强密钥,保证数据不被轻易窃取。

当然,我的项目中,加密的目的很单纯,就是为了避免百度云盘扫我的相片和个人文件。防止文件被窃取只是个附带的功能。

实测,encrypt2bdy项目中采用pyAesCrypt库后,处理500MB文件的过程中内存占用都不会超过90MB,非常完美。

密钥泄露问题?

更新后的项目还是采用环境变量的方式来加载用户密钥,至于环境变量方式是否会导致密钥泄露:别人都能看到你的docker配置了,他还取你的密钥干嘛,直接把本地源文件偷走了好吧……

就算不用环境变量,在配置文件/前端里面填密钥,最终都还是需要一个位置来存放这个密钥

除非直接把密钥存内存里面,不写入任何文件,且每次重启docker都要求用户重新填密钥。但是这样会导致容器可用性很差,毕竟每次操作docker容器都得重新弄一下配置。不过后续给某些对隐私要求高的老哥提供这个功能也不是不行。(这个功能必须要等前端写出来了之后才有可能实现)

分片加密上传(问题未解决)

修改了加密库还是不够。来和我一起看看当前项目上传文件的逻辑吧,假设用户选择了加密上传

  • 分片读取文件,计算文件md5用于本地入库
  • 分片读取和加密文件,并将加密后文件添加.e2bdy后缀写入源目录
  • 分片读取和上传加密后文件
  • 上传完毕,删除加密后文件(只保留源文件)
  • 循环处理,直到选定目录中所有文件都被处理完毕。

整个程序逻辑都采用了分片读取文件,内存占用问题是解决了,但还有另外一个问题没有解决:加密后文件需要写入磁盘,上传后又被删除。

也就是说,假设我需要备份100GB的文件,那么磁盘就会多出100GB的数据擦写……程序也需要累积等待100GB的磁盘写入,如果是机械硬盘……

而且,磁盘还需要保留有足量空间来存放这个临时的加密文件,假设我想备份一个10GB的单个文件,磁盘剩余可用空间只剩5GB了,此时程序就无法将加密后的文件写入磁盘(一般加密后的文件会比原始文件略大一些),导致无法处理这个文件了。

这样肯定不行!所以我换了一个思路,百度云盘要求数据按4MB的分片调用API上传,那么我们可否每次读取4MB的文件内容,加密它并保存在内存中,随后直接上传这个分片呢?这样整个的处理流程中,都是分片后在内存中处理,也不会多出来一个临时的加密文件导致的数据擦写,项目可用性提高!

问了GPT,写了个这样的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import io
import os
import pyAesCrypt

# 加密函数
def encrypt_chunk(chunk_data, password):
# 加密数据块
with io.BytesIO() as block_encrypted:
pyAesCrypt.encryptStream(
io.BytesIO(chunk_data),
block_encrypted,
password
)

# 返回加密后的数据
return block_encrypted.getvalue()

# 上传函数
def upload_chunk_to_cloud(chunk_data):
# 将加密后的数据块上传到云端
pass

# 主程序
if __name__ == '__main__':
src_path = 'path/to/source/file'
password = 'your-password'

with open(src_path, 'rb') as src_file:
while True:
# 读取一个数据块
chunk = src_file.read(4 * 1024 * 1024)
if not chunk:
# 数据块读取完毕,退出循环
break

# 加密数据块
encrypted_chunk = encrypt_chunk(chunk, password)

# 上传加密后的数据块到云端
upload_chunk_to_cloud(encrypted_chunk)

现在我要做的,就是验证一下这样操作是否能上传成功、是否能解密上传后的文件,然后再看看内存占用多少。

分片加密和直接加密区别

然而第一部我就卡住了:百度云盘的API要求上传前先传入文件的完整md5,也就是说,我得想个办法拿到加密后文件的完整md5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import io
import os
import hashlib
import pyAesCrypt


# 加密函数
def encrypt_chunk(chunk_data, password):
# 加密数据块
with io.BytesIO() as block_encrypted:
pyAesCrypt.encryptStream(io.BytesIO(chunk_data), block_encrypted,
password)

# 返回加密后的数据
return block_encrypted.getvalue()


def encrypt_file(input_file_path: str, passwd):
"""
加密文件,采用分片读取
:param input_file_path: 需要加密的源文件
:return 加密后的文件路径
"""
encrypt_file_path = input_file_path + ".ept"
with open(input_file_path, 'rb') as file_in, open(encrypt_file_path,
'wb') as file_out:
pyAesCrypt.encryptStream(file_in, file_out, passwd, 4 * 1024 * 1024)
return encrypt_file_path

# 主程序
if __name__ == '__main__':
src_path = '/home/mu/code-wsl/py-wsl/encrypt2bdy/test/CloudDrive2Setup-X64-0.5.14.exe'
password = 'test'
ept_file_path = encrypt_file(src_path,password)
# 读取加密后文件计算md5
with open(ept_file_path, 'rb') as f:
file_md5_str = hashlib.md5(f.read()).hexdigest()
print("full encrypt",file_md5_str)

# 分片加密并计算md5
file_md5_str = hashlib.md5()
with open(src_path, 'rb') as src_file:
while True:
# 读取一个数据块
chunk = src_file.read(4 * 1024 * 1024)
if not chunk:
# 数据块读取完毕,退出循环
break

# 加密数据块
encrypted_chunk = encrypt_chunk(chunk, password)
file_md5_str.update(encrypted_chunk)

print("chunk encrypt",file_md5_str.hexdigest())

使用如上代码分片加密后的整个文件,和直接使用加密库来加密的文件是不一样的,即便这两个操作都用了相同大小的分片块。

1
2
3
╰─ python3.10 test.py
full encrypt e994f5c7244fa7d6e9577e2089a029bc
chunk encrypt bdd05213bcafe4f87470c90633d7c2bc

但如果把分片后的文件写入本地,再计算md5,会发现和单个计算的md5相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
   # 分片加密并计算md5
file_md5_str = hashlib.md5()
# 分片加密后保存到本地的文件路径
chunk_encrpy_file = src_path + '.test'

with open(chunk_encrpy_file,'wb') as ef:
with open(src_path, 'rb') as src_file:
while True:
# 读取一个数据块
chunk = src_file.read(4 * 1024 * 1024)
if not chunk:
break # 数据块读取完毕,退出循环

# 加密数据块
encrypted_chunk = encrypt_chunk(chunk, password)
file_md5_str.update(encrypted_chunk)
ef.write(encrypted_chunk)
# 分片加密得到的最终md5
print("chunk encrypt",file_md5_str.hexdigest())
# 计算加密后保存到本地的文件的md5
print("chunk encrypt file:",file_md5(chunk_encrpy_file))

运行结果如下

1
2
chunk encrypt 843ef6583227592da36e563c1700d0da
chunk encrypt file: 843ef6583227592da36e563c1700d0da

也就是说,虽然这种方式和直接加密整个文件,得到的最终文件是不一样的,但我们依旧可以通过用相同办法进行加密和解密操作,来进行分片加密上传和分片解密文件(吗?)

1
raise ValueError("Bad HMAC (file is corrupted).")

无法解密……G!

要期末了,等放假了再继续研究这个问题。

放假了……但我还是没有弄明白有没有办法实现内存中分片后直接上传。