commit efda3dd3d865915aaf27c838bd5ba7f147eb8d9b Author: motius Date: Mon Apr 6 19:40:09 2020 +0200 [dev] backup authentication tool source tool to create an archive of all authentication files on one's computer and encrypt it in memory with AES-256-CBC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4b9ad2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/venv* +**/*.sw* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..49527a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +astroid==2.3.3 +isort==4.3.21 +lazy-object-proxy==1.4.3 +mccabe==0.6.1 +pycrypto==2.6.1 +pylint==2.4.4 +six==1.14.0 +wrapt==1.11.2 diff --git a/src/backup_authentication.py b/src/backup_authentication.py new file mode 100755 index 0000000..011326d --- /dev/null +++ b/src/backup_authentication.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +################################################################################ + +""" +backup and encrypt local authentication files +Python >= 3.8 required +""" + +import os +import io +import sys +import time +import argparse as ap +import pathlib as pl +import tarfile as tf +import getpass +import base64 +import hashlib as hl + +from Crypto import Random +from Crypto.Cipher import AES + +################################################################################ + +# Crypto Python >= 3.3 compat https://bugs.python.org/issue14309 +time.clock = time.time + +HOME = os.path.expanduser('~') +filenames = [ + pl.Path(HOME, filename) for filename in ( + ".ssh/config", + ) +] +dirnames = [ + pl.Path(HOME, dirent) for dirent in ( + ".ssh", + ".gnupg", + ".password-store", + ) +] +RES_FILE = pl.Path(HOME, "backup_authentication.tar.gz.enc") +TARFILE = pl.Path(HOME, "backup_authentication.tar.gz") +DESCRIPTION = \ +r"""backup_authentication creates an encrypted backup of standard authentication +files for *nix systems, e.g. .ssh/, .password-store/, .gnupg/. +""" +MANUAL = \ +"""backup_authentication encrypts data in memory, first creating a tarball +archive and then using AES-256-CBC to encrypt it. It requires Python3.8+ to +run. You can customize the files and directories to backup and encrypt in +the configuration above with the variables and . +""" + +################################################################################ + +class Singleton(type): + """this is a metaclass. Class definition are defined with + metaclasses. + This class enforces uniqueness of instances of a class whose + metaclass is this Singleton. In other words, if a class inherits + from this class, then all instances objects of this class are the + same, i.e. point to the same value in memory + """ + _instances = {} + + def __call__(cls, *args, **kwargs): + # delete singleton. e.g. ClassName(delete_singleton=True) + if kwargs.get("delete_singleton", None): + cls._instances = {} + return None + # create singleton instance if not exists. return instance. + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class Logger(metaclass=Singleton): + """module logger + """ + @staticmethod + def log(msg, *args, **kwargs): + """log everything + """ + print(msg, *args, **kwargs) + + @classmethod + def display(cls, msg, *args, **kwargs): + cls.log(msg, *args, **kwargs) + + @classmethod + def error(cls, msg, *args, **kwargs): + cls.log(msg, *args, **kwargs) + + @classmethod + def warn(cls, msg, *args, **kwargs): + cls.log(msg, *args, **kwargs) + + + +class AESCipher: + """modified from + https://stackoverflow.com/questions/12524994/encrypt-decrypt-using-pycrypto-aes-256 + """ + def __init__(self, key: str): + """initialize an object which can encrypt or decrypt bytes with + AES-256-CBC cypher + """ + self.bs = AES.block_size + self.key = hl.sha256(key.encode()).digest() + + def encrypt(self, raw: bytes) -> bytes: + """encrypt bytes with AES-256-CBC cypher + + """ + raw = self._pad(raw) + iv = Random.new().read(AES.block_size) + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return base64.b64encode(iv + cipher.encrypt(raw)) + + def decrypt(self, enc: bytes): + """decrypt bytes with AES-256-CBC cypher + """ + enc = base64.b64decode(enc) + iv = enc[:AES.block_size] + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return self._unpad(cipher.decrypt(enc[AES.block_size:])) + + def _pad(self, s): + """helper function used to pad data to a block for AES. + """ + return s + ((self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs)).encode() + + @staticmethod + def _unpad(s): + """helper function used to unpad encrypted data in the last AES block + """ + return s[:-ord(s[len(s)-1:])] + +################################################################################ + +def exit_error(msg): + """exit program with an error + """ + if msg: + LOGGER.error(msg) + sys.exit(1) + + +def password_prompt(retry: bool = False) -> str: + """prompt user for password and verification. Check equality + """ + while len(s := {getpass.getpass("password: "), getpass.getpass("retype password: ")}) != 1: + if len(s) == 1: + break + if not retry: + return exit_error("ERROR: passwords do NOT match") + LOGGER.warn("passwords do NOT match") + return next(iter(s)) + + +def find_all_matching_dirs_and_files(dirnames: list, filenames: list) -> list: + """ + """ + dirs_and_files = set() + for dirent in dirnames: + for root, dirs, files in os.walk(dirent, followlinks=False): + for dirname in dirs: + dirs_and_files.add(pl.Path(root, dirname)) + for filename in files: + dirs_and_files.add(pl.Path(root, filename)) + + for filename in filenames: + dirs_and_files.add(pl.Path(filename)) + bad_paths = tuple(entry for entry in dirs_and_files if not os.path.exists(entry)) + assert not bad_paths, f"Invalid path / files: {bad_paths}" + LOGGER.log(f"Found {len(dirs_and_files)} directories and files to backup.") + return dirs_and_files + + +def create_tar_archive(password: str, dirs_and_files: set): + """ + """ + buf = io.BytesIO() + with tf.open(name=None, mode="w:gz", fileobj=buf) as archive: + for entry in dirs_and_files: + archive.add(entry) + buf.seek(0) + aes_tool = AESCipher(password) + encrypted_data = aes_tool.encrypt(buf.read()) + with open(RES_FILE, mode="wb") as fwb: + fwb.write(encrypted_data) + LOGGER.log("OK: archive created") + + +def parse_args(): + """parse program arguments + """ + parser = ap.ArgumentParser(description=DESCRIPTION) + parser.add_argument("-e", + "--encrypt", + help="create an encrypted backup of files", + action="store_true", + dest="ACTION", + default=True + ) + parser.add_argument("-d", + "--decrypt", + help="decrypt AES archive", + action="store_false", + dest="ACTION", + default=True, + ) + parser.add_argument("-m", + "--man", + help="advanced manual for backup_authentication", + action="store_true", + dest="MANUAL", + default=False, + ) + return parser + + +def main(): + """main function for encryption + """ + password = password_prompt(retry=False) + dirs_and_files = find_all_matching_dirs_and_files(dirnames, filenames) + create_tar_archive(password, dirs_and_files) + + +def decrypt(): + """reverse encryption, write tar.gz backup file + """ + with open(RES_FILE, mode='rb') as frb: + aes_tool = AESCipher(getpass.getpass("password: ")) + data = aes_tool.decrypt(frb.read()) + with open(TARFILE, mode="wb") as fwb: + fwb.write(data) + LOGGER.log("OK: archive decrypted") + +################################################################################ + +LOGGER = Logger() + +if __name__ == "__main__": + parser = parse_args() + args = parser.parse_args() + if args.MANUAL: + LOGGER.log(MANUAL) + sys.exit(0) + action = args.ACTION + if action is True: + main() + elif action is False: + decrypt() + else: + LOGGER.display(f"Nothing to do: {ACTION}") + +################################################################################