[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:
commit
efda3dd3d8
|
@ -0,0 +1,2 @@
|
||||||
|
**/venv*
|
||||||
|
**/*.sw*
|
|
@ -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
|
|
@ -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}")
|
||||||
|
|
||||||
|
################################################################################
|
Loading…
Reference in New Issue