def decrypt(f=None, s=None, pwd=None, out=None): if out is None: out = io.BytesIO() if f is None: f = io.BytesIO(s) salt = f.read(16) nonce = f.read(16) tag = f.read(16) if salt not in _KEYCACHE: _KEYCACHE[salt] = KDF(pwd, salt)[0] cipher = Crypto.Cipher.AES.new(_KEYCACHE[salt], Crypto.Cipher.AES.MODE_GCM, nonce=nonce) while True: block = f.read(BLOCKSIZE) if not block: break out.write(cipher.decrypt(block)) try: cipher.verify(tag) except ValueError: print('Incorrect key or file corrupted.') out.seek(0) return out
def backup(src=None, dest=None, sftppwd=None, encryptionpwd=None): """Do a backup of `src` (local path) to `dest` (SFTP). The files are encrypted locally and are *never* decrypted on `dest`. Also, `dest` never gets the `encryptionpwd`.""" if os.path.isdir(src): os.chdir(src) else: print('Source directory does not exist.') return remote, user, host, remotepath = parseaddress(dest) if not remote or not user or not host or not remotepath: # either not remote (local), or remote with empty user, host or remotepath print( 'dest should use the following format: [email protected]:/path/to/backup/' ) return print( 'Starting backup...\nSource path: %s\nDestination host: %s\nDestination path: %s' % (src, host, remotepath)) if sftppwd is None: sftppwd = getpass.getpass( 'Please enter the SFTP password for user %s: ' % user) if encryptionpwd is None: encryptionpwd = getpass.getpass( 'Please enter the encryption password: '******'Destination directory does not exist.') return ######## GET DISTANT FILES INFO print('Distant files list: getting...') DELS = b'' DISTANTFILES = dict() DISTANTHASHES = dict() distantfilenames = set(sftp.listdir()) DISTANTCHUNKS = { bytes.fromhex(f) for f in distantfilenames if '.' not in f } # discard .files and .tmp files for f in distantfilenames: # remove old distant temp files if f.endswith('.tmp'): sftp.remove(f) flist = io.BytesIO() if sftp.isfile('.files'): sftp.getfo('.files', flist) flist.seek(0) while True: l = flist.read(4) if not l: break length = int.from_bytes(l, byteorder='little') s = flist.read(length) if len(s) != length: print( 'Item of .files is corrupt. Last sync interrupted?' ) break chunkid, mtime, fsize, h, fn = readdistantfileblock( s, encryptionpwd) DISTANTFILES[fn] = [chunkid, mtime, fsize, h] if DISTANTFILES[fn][0] == NULL16BYTES: # deleted del DISTANTFILES[fn] if chunkid in DISTANTCHUNKS: DISTANTHASHES[ h] = chunkid # DISTANTHASHES[sha256_noencryption] = chunkid ; even if deleted file keep the sha256, it might be useful for moved/renamed files for fn, distantfile in DISTANTFILES.items(): if not os.path.exists(fn): print(' %s no longer exists (deleted or moved/renamed).' % fn) DELS += newdistantfileblock(chunkid=NULL16BYTES, mtime=0, fsize=0, h=NULL32BYTES, fn=fn, key=key, salt=salt) if len(DELS) > 0: with sftp.open('.files', 'a+') as flist: flist.write(DELS) print('Distant files list: done.') ####### SEND FILES REQUIREDCHUNKS = set() with sftp.open('.files', 'a+') as flist: for fn in glob.glob('**/*', recursive=True): if os.path.isdir(fn): continue mtime = os.stat(fn).st_mtime_ns fsize = os.path.getsize(fn) if fn in DISTANTFILES and DISTANTFILES[fn][ 1] >= mtime and DISTANTFILES[fn][2] == fsize: print( 'Already on distant: unmodified (mtime + fsize). Skipping: %s' % fn) REQUIREDCHUNKS.add(DISTANTFILES[fn][0]) else: h = getsha256(fn) if h in DISTANTHASHES: # ex : chunk already there with same SHA256, but other filename (case 1 : duplicate file, case 2 : renamed/moved file) print( 'New, but already on distant (same sha256). Skipping: %s' % fn) chunkid = DISTANTHASHES[h] REQUIREDCHUNKS.add(chunkid) else: print('New, sending file: %s' % fn) chunkid = uuid.uuid4().bytes with sftp.open(chunkid.hex() + '.tmp', 'wb') as f_enc, open(fn, 'rb') as f: encrypt(f, key=key, salt=salt, out=f_enc) sftp.rename(chunkid.hex() + '.tmp', chunkid.hex()) REQUIREDCHUNKS.add(chunkid) DISTANTHASHES[h] = chunkid flist.write( newdistantfileblock(chunkid=chunkid, mtime=mtime, fsize=fsize, h=h, fn=fn, key=key, salt=salt) ) # todo: accumulate in a buffer and do this every 10 seconds instead delchunks = DISTANTCHUNKS - REQUIREDCHUNKS if len(delchunks) > 0: print('Deleting %s no-longer-used distant chunks... ' % len(delchunks), end='') for chunkid in delchunks: sftp.remove(chunkid.hex()) print('done.') print('Backup finished.') except paramiko.ssh_exception.AuthenticationException: print('Authentication failed.') except paramiko.ssh_exception.SSHException as e: print( e, '\nPlease ssh your remote host at least once before, or add your remote to your known_hosts file.\n\n' ) # todo: avoid ugly error messages after