[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
This commit is contained in:
motius 2020-04-06 19:40:09 +02:00
commit efda3dd3d8
3 changed files with 270 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
**/venv*
**/*.sw*

8
requirements.txt Normal file
View File

@ -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

260
src/backup_authentication.py Executable file
View File

@ -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 <filenames> and <dirnames>.
"""
################################################################################
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}")
################################################################################