基于Python的Rclone Crypt储存解密

警告
本文最后更新于 2023-07-25,文中内容可能已过时。
rclone_python.png

Rclone支持将其他储存添加为Crypt类型的储存,用于加密存储的文件。

本文旨在简要介绍Rclone Crypt储存的加密原理,并给出Python实现的解密代码。在本文基础上,编写了rclone加密/解密代码rclone_crypt_py

rclone加密有两个可设置的密码,分别记作passwd1和passwd2。其中passwd2可不设置,若不设置则使用默认值,默认值为\xA8\x0D\xF4\x3A\x8F\xBD\x03\x08\xA7\xCA\xB8\x3E\x58\x1F\x86\xB1

基于passwd1和passwd2,生成用于文件加密和文件名加密的密钥。生成密钥的算法为scrypt,scrypt算法的参数为N=16384, r=8, p=1,生成密钥的长度为80bytes。其中前32bytes用于文件加密,32-64bytes和64bytes之后的数据用于文件名加密。

通过passwd1和passwd2生成加密密钥的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from Crypto.Protocol.KDF import scrypt

KEY_SIZE = 32 + 32 + 16 # scrypt生成密钥的长度
DEFAULT_SALT = b"\xA8\x0D\xF4\x3A\x8F\xBD\x03\x08\xA7\xCA\xB8\x3E\x58\x1F\x86\xB1" # rclone默认salt

salt = passwd2 if passwd2 else DEFAULT_SALT
key = scrypt(passwd, salt, KEY_SIZE, 16384, 8, 1)
dataKey = key[:32]
nameKey = key[32 : 64]
nameTweak = key[64 :]

加密后的文件由两部分组成,文件头和数据块。

其中文件头分为两部分:

  • 固定rclone文件头

    8 bytes,RCLONE\x00\x00

  • 用于数据块解密的Nonce

    24 bytes

文件头中的Nonce是加密时系统随机生成的,用于初始数据块加密。

每加密一个数据块,Nonce改变一次,以确保每个数据块的Nonce不同。在rclone的加密算法中,更改Nonce的方法为最左一位byte数字加一。实现代码为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 单byte加1
def byte_increment(byte: int) -> int:
	if (byte > 255):
		raise ValueError('byte must be in range(0, 256)')
	return (byte + 1) if (byte < 255) else 0

# nonce加1
def nonce_increment(nonce: bytes, start: int = 0) -> bytes:
	nonce_array = bytearray(nonce) # 转为数组
	# 加1操作
	for i in range(start, len(nonce)):
		digit = nonce_array[i]
		newDigit = byte_increment(digit)
		nonce_array[i] = newDigit
		if newDigit >= digit:
			break
	return bytes(nonce_array) #转回bytes

除了最后一个数据块外,每个数据块包含16 + 64 * 1024bytes数据,其中16bytes是用于验证的数据头,64 * 1024bytes是64 * 1024bytes原始数据加密后得到的数据。

文件块的加密算法是Nacl算法

使用python实现的解密代码为:

 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
import nacl.secret

box = nacl.secret.SecretBox(dataKey)

def file_decrypt(self, input_file_path: str, output_file_path: str) -> None:
	try:
		input_file = open(input_file_path, 'rb')
	except:
		raise FileNotFoundError('input file not found')
	try:
		output_file = open(output_file_path, 'wb')
	except:
		raise ValueError('failed to write output file')
	# 读取头
	if not input_file.read(FILEMAGIC_SIZE) == b'RCLONE\x00\x00': # 标准头
		raise ValueError('not encrypted rclone file')
	Nonce = input_file.read(FILENONCE_SIZE)
	# 读取文件块
	# 16为头
	# 64kb数据
	input_bytes = input_file.read(BLOCKDATA_SIZE + BLOCKHEADER_SIZE)
	try:
		while (input_bytes):
			output_file.write(box.decrypt(input_bytes, Nonce))
			Nonce = nonce_increment(Nonce)
			input_bytes = input_file.read(BLOCKDATA_SIZE + BLOCKHEADER_SIZE)
	except:
		raise RuntimeError('failed to decrypt file')
	input_file.close()
	output_file.close()

文件名有两种加密方式:

  • standard
    1. 文件名补全为16bytes的倍数,补全算法为PKCS#7
    2. 使用EME(ECB-Mix-ECB)算法对补全后的文件名进行加密

    使用生成的nameKey和nameTweak进行加密

    1. 对加密结果进行base32编码
    2. 去除结果中的=
  • obfuscate

    简单的文件名混淆,每个文件名都有一个对应的混淆距离。加密后的文件名为:<混淆距离>.<混淆后文件名>

standard解密代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from Crypto.Cipher import AES
from .eme import Decrypt

cipher = AES.new(nameKey, AES.MODE_ECB)

def name_standard_decrypt(self, filename: str) -> str:
	if filename == '':
		return ''
	padding_num = 8 - len(filename) % 8
	filename = filename + padding_num * '=' # 添加padding
	filename = base64.b32hexdecode(filename.upper()) # base32解码
	if len(filename) == 0:
		raise ValueError('too short to decrypt')
	if len(filename) >= 2048:
		raise ValueError('too long to decrypt')
	return unpad(Decrypt(cipher, nameTweak, filename), 16, style = 'pkcs7').decode('utf-8') # EME解密

obfuscate解密代码:

 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
55
56
def name_obfuscate_decrypt(self, filename: str) -> str:
	if filename == '':
		return ''
	pos = filename.find('.')
	if pos == -1:
		raise ValueError('not obfuscate encrypted filename')
	num = filename[: pos]
	if num == '!':
		return filename[pos + 1 :]
	try:
		dir_ = int(num)
	except:
		raise ValueError('not obfuscate encrypted filename')
	for i in self.__nameKey:
		dir_ = dir_ + i
	inQuote = False
	out_filename = ''
	for str_ in filename[pos + 1 :]:
		code = ord(str_)
		if inQuote:
			out_filename = out_filename + str_
		elif code == ord('!'):
			inQuote = True
		elif code >= ord('0') and code <= ord('9'):
			thisdir = (dir_ % 9) + 1
			newRune = ord('0') + code - '0' - thisdir
			if newRune < ord('0'):
				newRune = newRune + 10
			out_filename = out_filename + chr(newRune)
		elif (code >= ord('A') and code <= ord('Z')) or (code >= ord('a') and code <= ord('z')):
			thisdir = dir_ % 25 + 1
			pos = code - ord('A')
			if pos >= 26:
				pos = pos -6
			pos = pos - thisdir
			if pos < 0:
				pos = pos + 52
			if pos >= 26:
				pos = pos + 6
			out_filename = out_filename + chr(ord('A') + pos)
		elif code >= 0xa0 and code <= 0xff:
			thisdir = (dir_ % 95) + 1
			newRune = 0xa0 + code - 0xa0 - thisdir
			if newRune < 0xa0:
				newRune = newRune + 96
			out_filename = out_filename + chr(newRune)
		elif code >= 0x100:
			thisdir = (dir_ % 127) + 1
			base = code - code % 256
			newRune = base + code - base - thisdir
			if newRune < base:
				newRune = newRune + 256
			out_filename = out_filename + chr(newRune)
		else:
			out_filename = out_filename + chr(code)
	return out_filename