└── hyperbackup.py /hyperbackup.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import configparser 3 | import getpass 4 | import lz4 5 | import os 6 | import sqlite3 7 | import struct 8 | import sys 9 | 10 | from Crypto.Cipher import AES 11 | from Crypto.Cipher import PKCS1_v1_5 12 | from Crypto.Hash import MD5 13 | from Crypto.Hash import SHA256 14 | from Crypto.PublicKey import RSA 15 | 16 | # common paths setup 17 | bkpiPath = raw_input("Please enter the full path to a .bkpi file: ") 18 | if not os.path.isfile(bkpiPath) or bkpiPath[-5:] != ".bkpi": 19 | sys.exit("{0} is not a valid backup file!".format(bkpiPath)) 20 | baseDir = os.path.dirname(bkpiPath) 21 | extractDir = os.path.join(baseDir, "extract") 22 | configDir = os.path.join(baseDir, "Config") 23 | virtualFileIndexDir = os.path.join(configDir, "virtual_file.index") 24 | poolDir = os.path.join(baseDir, "Pool") 25 | chunkIndexDir = os.path.join(poolDir, "chunk_index") 26 | shareDir = os.path.join(configDir, "@Share") 27 | rsaPrivateKeyFile = os.path.join(extractDir, "private.pem") 28 | configFile = os.path.join(baseDir, "_Syno_TaskConfig") 29 | 30 | fileChunkIndexTemplate = "file_chunk{0}.index" 31 | poolIndexTemplate = "{0}.index" 32 | poolBucketTemplate = "{0}.bucket" 33 | 34 | # magic header number setup 35 | magicKeyFileHeader = "ekhtar" 36 | magicPwSection = "shpw" 37 | magicUnknownSection = "shpv" 38 | magicKeySection = "enpv" 39 | magicIndexHeader = struct.pack("i", 0x6ea85370) 40 | 41 | # hash salt setup 42 | passwordSalt = "5mNgudh053SUoMrZxoKG8GUWyj6kEtGO" 43 | unikeySalt1 = "CIpfMargmxetgFtkBmG3KqEiQ6qfqZgF" 44 | unikeySalt2 = "kkE7sRZRvnbVlJFofhD7WCXumXBGyzki" 45 | fileKeySalt = "8Llx6OSaDPzbwCkjG8eYc64GZGMIlMXm" 46 | 47 | # hashing utility functions 48 | def hashMD5(data): 49 | md5Hash = MD5.new() 50 | md5Hash.update(data) 51 | hash = md5Hash.digest() 52 | return hash 53 | 54 | def hashSHA256(data): 55 | sha256Hash = SHA256.new() 56 | sha256Hash.update(data) 57 | hash = sha256Hash.digest() 58 | return hash 59 | 60 | # AES-CBC decryption and encryption 61 | def decryptAESCBC(cipherText, key, iv): 62 | cipher = AES.new(key, AES.MODE_CBC, iv) 63 | paddedPlainText = cipher.decrypt(cipherText) 64 | plainText = paddedPlainText[:-ord(paddedPlainText[-1])] 65 | return plainText 66 | 67 | def encryptAESCBC(plainText, key, iv): 68 | padLength = 16 - (len(plainText) % 16) 69 | paddedPlainText = plainText + chr(padLength)*padLength 70 | cipher = AES.new(key, AES.MODE_CBC, iv) 71 | cipherText = cipher.encrypt(paddedPlainText) 72 | return cipherText 73 | 74 | # RSA decryption using private key and PKCS1 v1.5 cipher 75 | def decryptPrivateRSA(cipherText, privateKey): 76 | key = RSA.importKey(privateKey) 77 | cipher = PKCS1_v1_5.new(key) 78 | plainText = cipher.decrypt(cipherText, None) 79 | return plainText 80 | 81 | # lz4 decompression routine 82 | def uncompressLz4(compressedData, size): 83 | header = struct.pack(" 0, t[3]), encryptedFileNameList) 203 | fileOffsetList = [] 204 | for (decryptedFileName, encryptedFileName, offset, isDir, id) in decryptedFileNameList: 205 | decryptedFullFilePath = os.path.join(directory, decryptedFileName) 206 | encryptedFullFilePath = encryptedParentPath + encryptedFileName 207 | fileNameHash = parentDirectoryHash[4:8] + hashMD5(encryptedFullFilePath) 208 | if (fileNameHash != str(id)): 209 | sys.exit("Hash not equal!") 210 | if isDir: 211 | encryptedFullFilePath += "/" 212 | fileOffsetList.extend(_buildFileOffsetList(decryptedFullFilePath, encryptedFullFilePath, fileNameHash, cursor, key, iv)) 213 | else: 214 | fileOffsetList.append((decryptedFullFilePath, offset)) 215 | return fileOffsetList 216 | 217 | # extract, decrypt and uncompress a chunk from the bucket 218 | def extractChunk(bucketId, indexOffset, key, iv): 219 | poolIndexDir = os.path.join(poolDir, "0/0") 220 | 221 | poolIndexFileName = findFile(poolIndexDir, poolIndexTemplate.format(bucketId)) 222 | poolIndexEntry = readDataFromFile(poolIndexFileName, indexOffset, 28) 223 | chunkSize = struct.unpack(">I", poolIndexEntry[0:4])[0] 224 | chunkOffset = struct.unpack(">I", poolIndexEntry[4:8])[0] 225 | chunkOriginalSize = struct.unpack(">I", poolIndexEntry[8:12])[0] 226 | chunkChecksum = poolIndexEntry[12:28] 227 | 228 | bucketFileName = findFile(poolIndexDir, poolBucketTemplate.format(bucketId)) 229 | chunkCipherContent = readDataFromFile(bucketFileName, chunkOffset, chunkSize) 230 | chunkCompressedContent = decryptAESCBC(chunkCipherContent, key, iv) 231 | chunkUncompressedContent = uncompressLz4(chunkCompressedContent, chunkOriginalSize) 232 | if hashMD5(chunkUncompressedContent) != chunkChecksum: 233 | sys.exit("Chunk checksum not matched!") 234 | return chunkUncompressedContent 235 | 236 | # get a list of bucket ID and offset tuples from the chunk index file 237 | def getChunkIndexLocations(chunkDescriptorOffsets): 238 | chunkIndexLocations = [] 239 | chunkIndexFile = findFile(chunkIndexDir, "0.idx") 240 | with open(chunkIndexFile, "rb") as chunkIndex: 241 | for chunkDescriptorOffset in chunkDescriptorOffsets: 242 | chunkIndex.seek(chunkDescriptorOffset) 243 | chunkDescriptorEntry = chunkIndex.read(16) 244 | bucketId = struct.unpack(">I", chunkDescriptorEntry[0:4])[0] 245 | bucketIndexOffset = struct.unpack(">I", chunkDescriptorEntry[4:8])[0] 246 | bucketUnknownData = chunkDescriptorEntry[8:16] #possibly version and/or compression information 247 | chunkIndexLocations.append((bucketId, bucketIndexOffset)) 248 | return chunkIndexLocations 249 | 250 | # extract all chunks of a file and save them to fileName using the file chunks index 251 | def extractChunks(fileName, fileChunksIndexId, fileChunksOffset, key, iv): 252 | fileChunksIndexPath = os.path.join(configDir, fileChunkIndexTemplate.format(fileChunksIndexId)) 253 | fileChunksIndexFile = findFile(fileChunksIndexPath, "0.idx") 254 | 255 | chunkOffsets = [] 256 | with open(fileChunksIndexFile, "rb") as fileChunksIndex: 257 | header = fileChunksIndex.read(4) 258 | if (header != magicIndexHeader): 259 | sys.exit("Failed to open index file") 260 | fileChunksIndex.seek(fileChunksOffset) 261 | fileChunksEntry = fileChunksIndex.read(12) 262 | fileChunksUnknownData = fileChunksEntry[0:8] 263 | numberOfChunks = struct.unpack(">I", fileChunksEntry[8:12])[0] >> 3 264 | for i in range(0, numberOfChunks): 265 | chunkOffset = struct.unpack(">Q", fileChunksIndex.read(8))[0] #possibly chunkOffset only 4 bytes long 266 | chunkOffsets.append(chunkOffset) 267 | 268 | chunkLocations = getChunkIndexLocations(chunkOffsets) 269 | extractFileName = os.path.join(extractDir, fileName) 270 | extractFileDir = os.path.dirname(extractFileName) 271 | 272 | if not os.path.exists(extractFileDir): 273 | os.makedirs(extractFileDir) 274 | with open(extractFileName, "wb+") as outFile: 275 | for (bucketId, indexOffset) in chunkLocations: 276 | extractedData = extractChunk(bucketId, indexOffset, key, iv) 277 | outFile.write(extractedData) 278 | 279 | # extract and save a file from the virtual file table with offset fileOffset to fileName 280 | def extractFile(fileName, fileOffset, key, iv): 281 | if fileOffset < 0: # config.dss file in @AppConfig has file offset -1 282 | return 283 | virtualFileIndexFile = findFile(virtualFileIndexDir, "0.idx") 284 | with open(virtualFileIndexFile, "rb") as virtualFileIndex: 285 | header = virtualFileIndex.read(4); 286 | if (header != magicIndexHeader): 287 | sys.exit("Failed to open index file") 288 | virtualFileIndex.seek(fileOffset) 289 | fileEntry = virtualFileIndex.read(56) 290 | fileUnknownData1 = fileEntry[0:48] #possibly file meta information 291 | fileChunksIndexId = struct.unpack(">H", fileEntry[48:50])[0] 292 | fileUnknownData2 = fileEntry[50:52] 293 | fileChunksOffset = struct.unpack(">I", fileEntry[52:56])[0] 294 | extractChunks(fileName, fileChunksIndexId, fileChunksOffset, key, iv) 295 | 296 | # decrypt the AES decryption parameters (key and iv) for file decryption using the private RSA key 297 | def getFileDecryptionParameter(rsaPrivateKey): 298 | decryptionParameterDb = findFile(poolDir, "vkey.db") 299 | conn = sqlite3.connect(decryptionParameterDb) 300 | cursor = conn.cursor() 301 | cursor.execute("SELECT rsa_vkey, rsa_vkey_iv, checksum FROM vkey WHERE version_id = (SELECT MAX(version_id) FROM vkey)") 302 | res = cursor.fetchone() 303 | encryptedFileKey = str(res[0]) 304 | encryptedFileIv = str(res[1]) 305 | checksum = str(res[2]) 306 | 307 | encryptedParameterHash = hashMD5(encryptedFileKey + fileKeySalt + encryptedFileIv) 308 | if (encryptedParameterHash != checksum): 309 | sys.exit("Checksum test failed") 310 | 311 | fileKey = decryptPrivateRSA(encryptedFileKey, rsaPrivateKey) 312 | fileIv = decryptPrivateRSA(encryptedFileIv, rsaPrivateKey) 313 | return (fileKey, fileIv) 314 | 315 | # read the key file 316 | keyFile = findFile(configDir, "encKeys") 317 | (passwordHash, encryptedRsaPrivateKey) = readKeyFile(keyFile) 318 | 319 | # verify the password 320 | password = getpass.getpass("Please enter your password: ") 321 | if not verifyPassword(password, passwordHash): 322 | sys.exit("Wrong password entered") 323 | 324 | # extract the RSA private key 325 | unikey = readUnikeyFromConfig() 326 | rsaPrivateKey = decryptRsaPrivateKey(encryptedRsaPrivateKey, password, unikey) 327 | writeDataToFile(rsaPrivateKeyFile, rsaPrivateKey) 328 | 329 | # build a list of all available files and their offsets in the virtual file table 330 | fileOffsetList = buildFileOffsetList(rsaPrivateKey, unikey) 331 | 332 | # decrypt the AES decryption parameter for file decryption 333 | (fileKey, fileIv) = getFileDecryptionParameter(rsaPrivateKey) 334 | 335 | # extract and decrypt all files 336 | for (file, offset) in fileOffsetList: 337 | extractFile(file, offset, fileKey, fileIv) 338 | --------------------------------------------------------------------------------