From 44eaafe50370c076269e80d9252bca969814d238 Mon Sep 17 00:00:00 2001
From: Simeon Simeonov
Date: Tue, 15 Mar 2022 05:54:56 +0100
Subject: Restructure the project in ordere to replace distutils with setup
tools
---
src/etoolkit/__init__.py | 34 ++++
src/etoolkit/__main__.py | 341 +++++++++++++++++++++++++++++++++++++++
src/etoolkit/etoolkit.py | 406 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 781 insertions(+)
create mode 100644 src/etoolkit/__init__.py
create mode 100644 src/etoolkit/__main__.py
create mode 100755 src/etoolkit/etoolkit.py
(limited to 'src')
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py
new file mode 100644
index 0000000..e2925ef
--- /dev/null
+++ b/src/etoolkit/__init__.py
@@ -0,0 +1,34 @@
+# etoolkit
+# Copyright (C) 2021-2022 Simeon Simeonov
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+"""A simple toolkit for setting environment variables in a flexible way"""
+from .etoolkit import EtoolkitInstance, EtoolkitInstanceError
+
+__author__ = 'Simeon Simeonov'
+__version__ = '1.1.0'
+__license__ = 'GPL3'
+
+
+def int_or_str(value):
+ """Returns int value of value when possible"""
+ try:
+ return int(value)
+ except ValueError:
+ return value
+
+
+VERSION = tuple(map(int_or_str, __version__.split('.')))
+
+__all__ = ['EtoolkitInstance', 'EtoolkitInstanceError']
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py
new file mode 100644
index 0000000..603bb2e
--- /dev/null
+++ b/src/etoolkit/__main__.py
@@ -0,0 +1,341 @@
+# etoolkit
+# Copyright (C) 2021-2022 Simeon Simeonov
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+"""
+CLI entry point for the etoolkit package
+
+Examples:
+python -m etoolkit -h
+
+python -m etoolkit -p
+"""
+import argparse
+import errno
+import getpass
+import io
+import json
+import logging
+import os
+import sys
+
+import etoolkit
+
+DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s"
+DEFAULT_LOG_LEVEL = logging.WARNING
+
+logger = logging.getLogger(__name__)
+
+
+def decrypt_value(args: argparse.Namespace, config: dict):
+ """
+ Interactive function for decrypting value(s)
+
+ Prompts for master key password and then prompts for a value to decrypt
+
+ The decrypted value is printed to stdout
+
+ :param args: The arguments sent by the caller
+ :type args: arparse.Namespace
+
+ :param config: The config dict sent by the caller
+ :type config: dict
+ """
+ password_hash = None
+ pipe_input = None
+ if not os.isatty(sys.stdin.fileno()):
+ pipe_input = sys.stdin.read().strip()
+ if 'general' in config:
+ password_hash = config['general'].get('MASTER_PASSWORD_HASH')
+
+ if (
+ args.master_password_prompt
+ or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
+ ):
+ password = etoolkit.EtoolkitInstance.confirm_password_prompt(
+ password_hash, False
+ )
+ else:
+ password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
+
+ if pipe_input:
+ # the input came from stdin. No need to prompt
+ print(
+ 'Decrypted value: '
+ f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}'
+ )
+ return
+ while True:
+ try:
+ value = input('Value: ')
+ print(
+ 'Decrypted value: '
+ f'{etoolkit.EtoolkitInstance.decrypt(password, value)}'
+ )
+ if not args.multiple_values:
+ break
+ except KeyboardInterrupt:
+ print(os.linesep)
+ break
+ return
+
+
+def encrypt_value(args: argparse.Namespace, config: dict):
+ """
+ Interactive function for encrypting value(s)
+
+ Prompts for master key password and then prompts for a value to encrypt
+
+ The encrypted value is printed to stdout
+
+ :param args: The arguments sent by the caller
+ :type args: arparse.Namespace
+
+ :param config: The config dict sent by the caller
+ :type config: dict
+ """
+ password_hash = None
+ pipe_input = None
+ if not os.isatty(sys.stdin.fileno()):
+ pipe_input = sys.stdin.read().strip()
+ if 'general' in config:
+ password_hash = config['general'].get('MASTER_PASSWORD_HASH')
+
+ if (
+ args.master_password_prompt
+ or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
+ ):
+ password = etoolkit.EtoolkitInstance.confirm_password_prompt(
+ password_hash
+ )
+ else:
+ password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
+
+ if pipe_input:
+ # the input came from stdin. No need to prompt
+ print(
+ 'Encrypted value: '
+ f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}'
+ )
+ return
+ while True:
+ try:
+ if args.echo:
+ value = input('Value: ')
+ else:
+ value = getpass.getpass('Value: ')
+ print(
+ 'Encrypted value: '
+ f'{etoolkit.EtoolkitInstance.encrypt(password, value)}'
+ )
+ if not args.multiple_values:
+ break
+ except KeyboardInterrupt:
+ print(os.linesep)
+ break
+ return
+
+
+def main(inargs=None):
+ """main entry point"""
+ parser = argparse.ArgumentParser(
+ prog=__package__,
+ epilog=(
+ f'%(prog)s {etoolkit.__version__} by Simeon Simeonov '
+ '(sgs @ LiberaChat)'
+ ),
+ description='The following options are available',
+ )
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument(
+ 'instance',
+ metavar='',
+ nargs='?',
+ type=str,
+ help='The instance to be loaded',
+ )
+ group.add_argument(
+ '-d',
+ '--decrypt-value',
+ dest='decrypt_value',
+ action='store_true',
+ required=False,
+ help=(
+ 'Prompt for master password & value to decrypt, '
+ 'display the decrypted value and exit'
+ ),
+ )
+ group.add_argument(
+ '-e',
+ '--encrypt-value',
+ dest='encrypt_value',
+ action='store_true',
+ required=False,
+ help=(
+ 'Prompt for master password & value to encrypt, '
+ 'display the encrypted value and exit'
+ ),
+ )
+ group.add_argument(
+ '-l',
+ '--list',
+ dest='list',
+ action='store_true',
+ required=False,
+ help='List all defined instances',
+ )
+ group.add_argument(
+ '-p',
+ '--generate-master-password-hash',
+ dest='password_hash',
+ action='store_true',
+ required=False,
+ help='Prompt for master password, display the generated hash and exit',
+ )
+ parser.add_argument(
+ '-c',
+ '--config-file',
+ metavar='',
+ type=str,
+ default=os.path.expanduser(
+ os.environ.get('ETOOLKIT_CONFIG', '~/.etoolkit.json')
+ ),
+ dest='config_file',
+ help='JSON config file (default: ~/.etoolkit.json)',
+ )
+ parser.add_argument(
+ '-E',
+ '--echo',
+ dest='echo',
+ action='store_true',
+ help='Display the value to be encrypted (used together with -e)',
+ )
+ parser.add_argument(
+ '-m',
+ '--multiple-values',
+ dest='multiple_values',
+ action='store_true',
+ help=(
+ 'Prompt for more than one value when '
+ 'encrypting / decrypting until terminated '
+ '(Ctrl+C) (used together with -d / -e)'
+ ),
+ )
+ parser.add_argument(
+ '-P',
+ '--master-password-prompt',
+ dest='master_password_prompt',
+ action='store_true',
+ help=(
+ 'Force prompt for the master password even if the env. variable '
+ '"ETOOLKIT_MASTER_PASSWORD" is set'
+ ),
+ )
+ parser.add_argument(
+ '-q',
+ '--no-output',
+ dest='dump_output',
+ action='store_false',
+ default=True,
+ help='Do not print environment variables to stdout',
+ )
+ parser.add_argument(
+ '-s',
+ '--spawn',
+ metavar='',
+ type=str,
+ default='',
+ dest='spawn',
+ help='Spawn another process than $SHELL',
+ )
+ parser.add_argument(
+ '-v',
+ '--version',
+ action='version',
+ version=f'%(prog)s {etoolkit.__version__}',
+ help='Display program-version and exit',
+ )
+ args = parser.parse_args(inargs)
+ try:
+ with io.open(args.config_file, 'r', encoding='utf-8') as fp:
+ config_dict = json.load(fp)
+ except FileNotFoundError as e:
+ # do not raise exception if config-file is missing for:
+ # - decrypting value
+ # - encrypting value
+ # - password hash generation
+ if args.password_hash or args.decrypt_value or args.encrypt_value:
+ logger.warning(
+ "Configuration file %s is missing, although not required "
+ "by the provided parameters",
+ args.config_file,
+ )
+ config_dict = {}
+ else:
+ logger.error("Configuration file %s is missing", args.config_file)
+ raise SystemExit(errno.EIO) from e
+ except Exception as e:
+ logger.error("Unable to parse %r: %s", args.config_file, e)
+ raise SystemExit(errno.EIO) from e
+ try:
+ if args.decrypt_value:
+ decrypt_value(args, config_dict)
+ sys.exit(0)
+ if args.encrypt_value:
+ encrypt_value(args, config_dict)
+ sys.exit(0)
+ if args.password_hash:
+ master_password = (
+ etoolkit.EtoolkitInstance.confirm_password_prompt()
+ )
+ phash = etoolkit.EtoolkitInstance.get_new_password_hash(
+ master_password
+ )
+ print(f'Master password hash: {phash}')
+ sys.exit(0)
+ if args.list:
+ for instance_name in sorted(
+ config_dict.get('instances', {}).keys()
+ ):
+ print(instance_name)
+ sys.exit(0)
+
+ inst = etoolkit.EtoolkitInstance(args.instance, config_dict)
+ inst.prompt_func = etoolkit.EtoolkitInstance.confirm_password_prompt
+ env = inst.get_environ()
+
+ if args.dump_output:
+ inst.dump_env(env)
+
+ os.environ.update(env)
+
+ if args.spawn:
+ os.system(args.spawn)
+ else:
+ os.system(os.getenv('SHELL', 'bash'))
+ except KeyboardInterrupt:
+ logger.debug('KeyboardInterrupt')
+ print(os.linesep)
+ sys.exit(0)
+ except etoolkit.EtoolkitInstanceError as e:
+ logger.error('EtoolkitInstanceError: %s', e)
+ sys.exit(1)
+ except Exception as e:
+ logger.error('Unexpected exception: %s', e)
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ logging.basicConfig(level=DEFAULT_LOG_LEVEL, format=DEFAULT_LOG_FORMAT)
+ main()
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py
new file mode 100755
index 0000000..d8221a5
--- /dev/null
+++ b/src/etoolkit/etoolkit.py
@@ -0,0 +1,406 @@
+# etoolkit
+# Copyright (C) 2021-2022 Simeon Simeonov
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+"""The main module of the etoolkit package"""
+import base64
+import getpass
+import hashlib
+import os
+
+from cryptography.exceptions import InvalidTag
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+
+class EtoolkitInstanceError(Exception):
+ """EtoolkitInstanceError - Generic exceptions related to instances"""
+
+
+class EtoolkitInstance:
+ """A basic class representing a single instance"""
+
+ def __init__(self, name: str, data: dict):
+ """
+ :param name: Instance name
+ :type name: str
+
+ :param data: .etoolkit.json alike dict
+ :type data: dict
+ """
+ self._name = name
+ self._parent = None
+ self._raw_env_variables = {}
+ self._sensitive_env_variables = []
+ self._master_password = None
+ self._master_password_hash = None
+ self._prompt_func = None # function to use when prompting for input
+ try:
+ inst_data = data['instances'][name]
+ except KeyError as e:
+ raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e
+ if inst_data.get('ETOOLKIT_PARENT'):
+ self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data)
+ self._raw_env_variables.update(self._parent.raw_env_variables)
+ self._sensitive_env_variables.extend(
+ self._parent.sensitive_env_variables
+ )
+ if inst_data.get('ETOOLKIT_SENSITIVE'):
+ if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list):
+ raise EtoolkitInstanceError(
+ '"ETOOLKIT_SENSITIVE" must be a list'
+ )
+ self._sensitive_env_variables.extend(
+ inst_data['ETOOLKIT_SENSITIVE']
+ )
+ self._raw_env_variables.update(inst_data)
+ # remove non env. variable data
+ self._raw_env_variables.pop('ETOOLKIT_PARENT', None)
+ self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None)
+ if 'general' in data and 'MASTER_PASSWORD_HASH' in data['general']:
+ self._master_password_hash = data['general'][
+ 'MASTER_PASSWORD_HASH'
+ ]
+
+ @property
+ def master_password(self) -> str:
+ """master_password-property"""
+ return self._master_password
+
+ @master_password.setter
+ def master_password(self, value):
+ """master_password-property setter"""
+ self._master_password = value
+
+ @property
+ def master_password_hash(self) -> str:
+ """master_password_hash-property"""
+ return self._master_password_hash
+
+ @master_password_hash.setter
+ def master_password_hash(self, value):
+ """master_password_hash-property setter"""
+ self._master_password_hash = value
+
+ @property
+ def name(self) -> str:
+ """name-property"""
+ return self._name
+
+ @property
+ def prompt_func(self):
+ """prompt_func-property"""
+ return self._prompt_func
+
+ @prompt_func.setter
+ def prompt_func(self, value):
+ """prompt_func-property setter"""
+ self._prompt_func = value
+
+ @property
+ def raw_env_variables(self) -> dict:
+ """raw_env_variables-property"""
+ return self._raw_env_variables
+
+ @property
+ def sensitive_env_variables(self) -> list:
+ """sensitive_env_variables-property"""
+ return self._sensitive_env_variables
+
+ @staticmethod
+ def confirm_password_prompt(
+ password_hash: str = None, confirm: bool = True
+ ) -> str:
+ """
+ Prompts for master password and then for confirmation if `confirm` True
+
+ :param password_hash: Hash to compare with instead of confirm
+ :type password_hash: str
+
+ :param confirm: Confirm the password (and see if there is a match)
+ :type confirm: bool
+
+ :return: Password provided by the user
+ :rtype: str
+ """
+ try:
+ while True:
+ pass1 = getpass.getpass('Type master password: ')
+ if password_hash:
+ if EtoolkitInstance.password_matches(pass1, password_hash):
+ return pass1
+ print('Wrong password')
+ continue
+ if confirm:
+ pass2 = getpass.getpass('Confirm master password: ')
+ if not pass1 or pass1 != pass2:
+ print('The passwords are either empty or do not match')
+ continue
+ return pass1.strip()
+ except Exception as e:
+ raise EtoolkitInstanceError('Prompt error') from e
+
+ @staticmethod
+ def decrypt(password: str, edata: str) -> str:
+ """
+ Decrypts `edata` using `password`.
+
+ `edata` is in the following format:
+ enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
+
+ :param password: The password to generate the key with
+ :type password: str
+
+ :param edata: The data to be decrypted
+ :type edata: str
+
+ :return: The output string / decrypted data
+ :rtype: str
+ """
+ # check for supported versions
+ if not edata.startswith('enc-val$1$'):
+ raise EtoolkitInstanceError(
+ f'Unsupported encryption format: {edata}'
+ )
+ try:
+ salt, data = [base64.b64decode(t) for t in edata[10:].split('$')]
+ nonce = salt[:12]
+ aesgcm = AESGCM(
+ hashlib.scrypt(
+ password.encode('utf-8'),
+ salt=salt,
+ n=2**14,
+ r=8,
+ p=1,
+ dklen=32,
+ )
+ )
+ return aesgcm.decrypt(nonce, data, salt).decode()
+ except InvalidTag as e:
+ raise EtoolkitInstanceError(
+ f'Invalid tag when decrypting: {edata}'
+ ) from e
+ except Exception as e:
+ raise EtoolkitInstanceError(
+ f'Error when decrypting: {edata}'
+ ) from e
+
+ @staticmethod
+ def encrypt(password: str, data: str) -> str:
+ """
+ Encrypts `data` using `password`.
+
+ The output string is in the following format:
+ enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
+
+ :param password: The password to generate the key with
+ :type password: str
+
+ :param data: The data to be encrypted
+ :type data: str
+
+ :return: The output string
+ :rtype: str
+ """
+ salt = os.urandom(32)
+ aesgcm = AESGCM(
+ hashlib.scrypt(
+ password.encode('utf-8'),
+ salt=salt,
+ n=2**14,
+ r=8,
+ p=1,
+ dklen=32,
+ )
+ )
+ nonce = salt[:12]
+ edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt)
+ return (
+ f'enc-val$1${base64.b64encode(salt).decode()}$'
+ f'{base64.b64encode(edata).decode()}'
+ )
+
+ @staticmethod
+ def get_new_password_hash(password: str) -> str:
+ """
+ Returns a complete password hash based on `password`
+
+ This password hash is *not* used as a key when encrypting / decrypting
+ but only for optional check if a correct master password is provided.
+
+ :param password: The plaintext password
+ :type password: str
+
+ :return: The hashed version of `password`
+ :rtype: str
+ """
+ hash_algo = 'sha256'
+ iterations = 100000
+ salt = os.urandom(32)
+ key = hashlib.pbkdf2_hmac(
+ hash_algo, password.encode('utf-8'), salt, iterations
+ )
+ return (
+ f'pbkdf2_{hash_algo}${iterations}$'
+ f'{base64.b64encode(salt).decode()}$'
+ f'{base64.b64encode(key).decode()}'
+ )
+
+ @staticmethod
+ def parse_value(value, macros: dict):
+ """
+ Returns the value with all macros replaced by their values
+
+ If `value` is not of type 'str' simply return `value`
+
+ :param value: A simple value
+ :type value: object
+
+ :param macros: Macros mapping
+ :type macros: dict
+
+ :return: New value with all macros replaced by their values
+ :rtype: object
+ """
+ if not isinstance(value, str):
+ return value
+ for key, val in macros.items():
+ value = value.replace(key, val)
+ return value
+
+ @staticmethod
+ def password_matches(password: str, password_hash: str) -> bool:
+ """
+ Checks `password` agains s stored password hash
+
+ Hash format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64
+
+ :param password: password
+ :type password: str
+
+ :param password_hash: The pbkdf2_hmac password hash
+ :type password_hash: str
+
+ :return: True if the password matches or password_hash is None,
+ :rtype: bool
+ """
+ if password_hash is None:
+ return True
+ # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64
+ try:
+ tokens = password_hash.split('$')
+ key = hashlib.pbkdf2_hmac(
+ tokens[0].split('_')[1],
+ password.encode('utf-8'),
+ base64.b64decode(tokens[2]),
+ int(tokens[1]),
+ )
+ return key == base64.b64decode(tokens[3])
+ except Exception:
+ return False
+
+ def dump_env(self, env: dict):
+ """
+ Prints an environment dict to stdout.
+
+ :param env: The environment dict
+ :type env: dict
+ """
+ for key, value in env.items():
+ if key in self._sensitive_env_variables:
+ print(f'{key}: ***')
+ continue
+ print(f'{key}: {value}')
+
+ def get_environ(self) -> dict:
+ """
+ Generates a new environ dict
+
+ :return: New environment dict with all macros replaced by their values
+ :rtype: dict
+ """
+ macros = {
+ '%h': os.path.expanduser('~'),
+ '%i': self.name,
+ # '%f': self.get_full_name(),
+ '%u': getpass.getuser(),
+ }
+ new_env = {}
+ for key, value in sorted(
+ self._raw_env_variables.items(), key=lambda x: x[0]
+ ):
+ if not value:
+ # perhaps unset instead of skipping?
+ continue
+ if isinstance(value, str) and value.startswith('enc-val$1$'):
+ value = self._decrypt_value(value)
+ if isinstance(value, str) and value.endswith(':'):
+ # if 'value' ends with ':', append the existing value of
+ # os.environ[key] after the value of 'value'
+ new_env[key] = self.parse_value(
+ value, macros
+ ) + os.environ.get(key, '')
+ elif isinstance(value, str) and value.startswith(':'):
+ # if 'value' starts with ':', append after the existing value
+ # of os.environ[key]
+ new_env[key] = os.environ.get(key, '') + self.parse_value(
+ value, macros
+ )
+ else:
+ # completely overwrite the existing value of os.environ[key]
+ new_env[key] = self.parse_value(value, macros)
+ return new_env
+
+ def get_full_name(self, delimiter: str = '') -> str:
+ """
+ Returns the entire instance inheritence path separated by `delimiter`
+
+ The format is:
+ `grandparent-name``parent-name``instance-name`
+
+ :param delimiter: Delimiter to separate parent instance names by
+ :type delimiter: str
+
+ :return: Path in case this instance has parent, instance.name otherwise
+ :rtype: str
+ """
+ if self._parent is None:
+ return self.name
+ return self._parent.get_full_name(delimiter) + delimiter + self.name
+
+ def _decrypt_value(self, evalue: str) -> str:
+ """
+ Decrypts an encrypted value using the master password
+
+ The method prompts for the master password when it encounters its
+ first encrypted value since the creation of its EtoolkitInstance
+ object
+
+ `evalue` is in the following format:
+ enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
+
+ :param evalue: Encrypted value to be decrypted
+ :type evalue: str
+
+ :return: Decrypted value
+ :rtype: str
+ """
+ if self._master_password is None:
+ if self._prompt_func is None:
+ raise EtoolkitInstanceError(
+ 'Neither password or prompt function set'
+ )
+ self._master_password = self._prompt_func(
+ self._master_password_hash, confirm=False
+ )
+ return EtoolkitInstance.decrypt(self._master_password, evalue)
--
cgit v1.3