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