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 --- CHANGELOG.md | 22 +++ README.md | 11 +- etoolkit/__init__.py | 34 ---- etoolkit/__main__.py | 341 --------------------------------------- etoolkit/etoolkit.py | 406 ----------------------------------------------- pyproject.toml | 15 ++ setup.cfg | 44 +++++ setup.py | 56 ------- src/etoolkit/__init__.py | 34 ++++ src/etoolkit/__main__.py | 341 +++++++++++++++++++++++++++++++++++++++ src/etoolkit/etoolkit.py | 406 +++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 872 insertions(+), 838 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 etoolkit/__init__.py delete mode 100644 etoolkit/__main__.py delete mode 100755 etoolkit/etoolkit.py create mode 100644 pyproject.toml create mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/etoolkit/__init__.py create mode 100644 src/etoolkit/__main__.py create mode 100755 src/etoolkit/etoolkit.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2c16909 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## [1.1.0](https://github.com/blackm0re/etoolkit/tree/1.1.0) (2022-03-14) + +[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.0.0...1.1.0) + +**Changes:** + +- a master password can now be set using the *ETOOLKIT_MASTER_PASSWORD* env. variable + +- a new parameter *-P* / *--master-password-prompt* can be used in order to force password prompt + +- improved tests + +- use *setuptools* instead of the deprecated *distutils* + + +# [1.0.0](https://github.com/blackm0re/etoolkit/tree/1.0.0) (2021-12-23) + +**Changes:** + +- Initial release diff --git a/README.md b/README.md index 218ea7c..d558c36 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,12 @@ for processes that were not spawned by that same *etoolkit* session. ### Gentoo ```bash + # add sgs' custom repository using app-eselect/eselect-repository + eselect repository add sgs + + # ... or using layman (obsolete) layman -a sgs + emerge dev-python/etoolkit ``` @@ -285,7 +290,11 @@ bottom of your bash startup file: complete -W '$(compgen -W "$(etoolkit -l)")' etoolkit ``` -A complete bash completion script for *etoolkit* can be found here: [https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash](https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash) +A complete bash completion script for *etoolkit* can be found here: +[https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash](https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash) + + +## [Changelog](https://github.com/blackm0re/etoolkit/blob/master/CHANGELOG.md) ## Support and contributing diff --git a/etoolkit/__init__.py b/etoolkit/__init__.py deleted file mode 100644 index df53a55..0000000 --- a/etoolkit/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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-rc1' -__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/etoolkit/__main__.py b/etoolkit/__main__.py deleted file mode 100644 index 603bb2e..0000000 --- a/etoolkit/__main__.py +++ /dev/null @@ -1,341 +0,0 @@ -# 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/etoolkit/etoolkit.py b/etoolkit/etoolkit.py deleted file mode 100755 index d8221a5..0000000 --- a/etoolkit/etoolkit.py +++ /dev/null @@ -1,406 +0,0 @@ -# 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1173069 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = [ + "setuptools>=51", + "wheel" +] + +build-backend = "setuptools.build_meta" + + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-s" +testpaths = [ + "tests" +] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c218df8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,44 @@ +[metadata] +name = etoolkit +version = attr: etoolkit.__version__ +author = attr: etoolkit.__author__ +author_email = sgs@pichove.org +description = A simple toolkit for setting environment variables in a flexible way +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/blackm0re/etoolkit + +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Console + Intended Audience :: Developers + Intended Audience :: System Administrators + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Operating System :: POSIX + Topic :: Security :: Cryptography + +project_urls = + Bug Tracker = https://github.com/blackm0re/etoolkit/issues + Source = https://github.com/blackm0re/etoolkit + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.7 + +install_requires = + cryptography>=3.2 + +[options.packages.find] +where = src + +[options.entry_points] +console_scripts = + etoolkit = etoolkit.__main__:main diff --git a/setup.py b/setup.py deleted file mode 100644 index b4681c3..0000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- - -import setuptools - -import etoolkit - -with open('README.md', 'r', encoding='utf-8') as fh: - long_description = fh.read() - - -setuptools.setup( - name='etoolkit', - version=etoolkit.__version__, - author=etoolkit.__author__, - author_email='sgs@pichove.org', - description=( - 'A simple toolkit for setting environment variables in a flexible way' - ), - license=etoolkit.__license__, - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/blackm0re/etoolkit', - packages=setuptools.find_packages(), - exclude_package_data={'': ['.gitignore']}, - entry_points={ - 'console_scripts': [ - 'etoolkit=etoolkit.__main__:main', - ], - }, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - ( - 'License :: OSI Approved :: GNU General Public License v3 or later' - ' (GPLv3+)' - ), - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: Implementation', - 'Operating System :: POSIX', - 'Topic :: Security :: Cryptography', - ], - keywords='unix environment security cryptography', - project_urls={ - 'Bug Reports': 'https://github.com/blackm0re/etoolkit/issues', - 'Source': 'https://github.com/blackm0re/etoolkit', - }, - install_requires=["cryptography>=3.2"], - python_requires='>=3.7', -) 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