diff options
| author | Simeon Simeonov | 2022-03-15 05:54:56 +0100 |
|---|---|---|
| committer | Simeon Simeonov | 2022-03-15 05:54:56 +0100 |
| commit | 44eaafe50370c076269e80d9252bca969814d238 (patch) | |
| tree | f6c3964c92b428c9f68449f828610cf4add2a58e /src | |
| parent | 907f80e1889781a69edded00126b3e78ddeb7b8e (diff) | |
Restructure the project in ordere to replace distutils with setup tools
Diffstat (limited to 'src')
| -rw-r--r-- | src/etoolkit/__init__.py | 34 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 341 | ||||
| -rwxr-xr-x | src/etoolkit/etoolkit.py | 406 |
3 files changed, 781 insertions, 0 deletions
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 @@ | |||
| 1 | # etoolkit | ||
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | ||
| 3 | |||
| 4 | # This program is free software: you can redistribute it and/or modify | ||
| 5 | # it under the terms of the GNU General Public License as published by | ||
| 6 | # the Free Software Foundation, either version 3 of the License, or | ||
| 7 | # (at your option) any later version. | ||
| 8 | |||
| 9 | # This program is distributed in the hope that it will be useful, | ||
| 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 12 | # GNU General Public License for more details. | ||
| 13 | |||
| 14 | # You should have received a copy of the GNU General Public License | ||
| 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| 16 | """A simple toolkit for setting environment variables in a flexible way""" | ||
| 17 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | ||
| 18 | |||
| 19 | __author__ = 'Simeon Simeonov' | ||
| 20 | __version__ = '1.1.0' | ||
| 21 | __license__ = 'GPL3' | ||
| 22 | |||
| 23 | |||
| 24 | def int_or_str(value): | ||
| 25 | """Returns int value of value when possible""" | ||
| 26 | try: | ||
| 27 | return int(value) | ||
| 28 | except ValueError: | ||
| 29 | return value | ||
| 30 | |||
| 31 | |||
| 32 | VERSION = tuple(map(int_or_str, __version__.split('.'))) | ||
| 33 | |||
| 34 | __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 @@ | |||
| 1 | # etoolkit | ||
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | ||
| 3 | |||
| 4 | # This program is free software: you can redistribute it and/or modify | ||
| 5 | # it under the terms of the GNU General Public License as published by | ||
| 6 | # the Free Software Foundation, either version 3 of the License, or | ||
| 7 | # (at your option) any later version. | ||
| 8 | |||
| 9 | # This program is distributed in the hope that it will be useful, | ||
| 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 12 | # GNU General Public License for more details. | ||
| 13 | |||
| 14 | # You should have received a copy of the GNU General Public License | ||
| 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| 16 | """ | ||
| 17 | CLI entry point for the etoolkit package | ||
| 18 | |||
| 19 | Examples: | ||
| 20 | python -m etoolkit -h | ||
| 21 | |||
| 22 | python -m etoolkit -p | ||
| 23 | """ | ||
| 24 | import argparse | ||
| 25 | import errno | ||
| 26 | import getpass | ||
| 27 | import io | ||
| 28 | import json | ||
| 29 | import logging | ||
| 30 | import os | ||
| 31 | import sys | ||
| 32 | |||
| 33 | import etoolkit | ||
| 34 | |||
| 35 | DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" | ||
| 36 | DEFAULT_LOG_LEVEL = logging.WARNING | ||
| 37 | |||
| 38 | logger = logging.getLogger(__name__) | ||
| 39 | |||
| 40 | |||
| 41 | def decrypt_value(args: argparse.Namespace, config: dict): | ||
| 42 | """ | ||
| 43 | Interactive function for decrypting value(s) | ||
| 44 | |||
| 45 | Prompts for master key password and then prompts for a value to decrypt | ||
| 46 | |||
| 47 | The decrypted value is printed to stdout | ||
| 48 | |||
| 49 | :param args: The arguments sent by the caller | ||
| 50 | :type args: arparse.Namespace | ||
| 51 | |||
| 52 | :param config: The config dict sent by the caller | ||
| 53 | :type config: dict | ||
| 54 | """ | ||
| 55 | password_hash = None | ||
| 56 | pipe_input = None | ||
| 57 | if not os.isatty(sys.stdin.fileno()): | ||
| 58 | pipe_input = sys.stdin.read().strip() | ||
| 59 | if 'general' in config: | ||
| 60 | password_hash = config['general'].get('MASTER_PASSWORD_HASH') | ||
| 61 | |||
| 62 | if ( | ||
| 63 | args.master_password_prompt | ||
| 64 | or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None | ||
| 65 | ): | ||
| 66 | password = etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 67 | password_hash, False | ||
| 68 | ) | ||
| 69 | else: | ||
| 70 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | ||
| 71 | |||
| 72 | if pipe_input: | ||
| 73 | # the input came from stdin. No need to prompt | ||
| 74 | print( | ||
| 75 | 'Decrypted value: ' | ||
| 76 | f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' | ||
| 77 | ) | ||
| 78 | return | ||
| 79 | while True: | ||
| 80 | try: | ||
| 81 | value = input('Value: ') | ||
| 82 | print( | ||
| 83 | 'Decrypted value: ' | ||
| 84 | f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' | ||
| 85 | ) | ||
| 86 | if not args.multiple_values: | ||
| 87 | break | ||
| 88 | except KeyboardInterrupt: | ||
| 89 | print(os.linesep) | ||
| 90 | break | ||
| 91 | return | ||
| 92 | |||
| 93 | |||
| 94 | def encrypt_value(args: argparse.Namespace, config: dict): | ||
| 95 | """ | ||
| 96 | Interactive function for encrypting value(s) | ||
| 97 | |||
| 98 | Prompts for master key password and then prompts for a value to encrypt | ||
| 99 | |||
| 100 | The encrypted value is printed to stdout | ||
| 101 | |||
| 102 | :param args: The arguments sent by the caller | ||
| 103 | :type args: arparse.Namespace | ||
| 104 | |||
| 105 | :param config: The config dict sent by the caller | ||
| 106 | :type config: dict | ||
| 107 | """ | ||
| 108 | password_hash = None | ||
| 109 | pipe_input = None | ||
| 110 | if not os.isatty(sys.stdin.fileno()): | ||
| 111 | pipe_input = sys.stdin.read().strip() | ||
| 112 | if 'general' in config: | ||
| 113 | password_hash = config['general'].get('MASTER_PASSWORD_HASH') | ||
| 114 | |||
| 115 | if ( | ||
| 116 | args.master_password_prompt | ||
| 117 | or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None | ||
| 118 | ): | ||
| 119 | password = etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 120 | password_hash | ||
| 121 | ) | ||
| 122 | else: | ||
| 123 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | ||
| 124 | |||
| 125 | if pipe_input: | ||
| 126 | # the input came from stdin. No need to prompt | ||
| 127 | print( | ||
| 128 | 'Encrypted value: ' | ||
| 129 | f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' | ||
| 130 | ) | ||
| 131 | return | ||
| 132 | while True: | ||
| 133 | try: | ||
| 134 | if args.echo: | ||
| 135 | value = input('Value: ') | ||
| 136 | else: | ||
| 137 | value = getpass.getpass('Value: ') | ||
| 138 | print( | ||
| 139 | 'Encrypted value: ' | ||
| 140 | f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' | ||
| 141 | ) | ||
| 142 | if not args.multiple_values: | ||
| 143 | break | ||
| 144 | except KeyboardInterrupt: | ||
| 145 | print(os.linesep) | ||
| 146 | break | ||
| 147 | return | ||
| 148 | |||
| 149 | |||
| 150 | def main(inargs=None): | ||
| 151 | """main entry point""" | ||
| 152 | parser = argparse.ArgumentParser( | ||
| 153 | prog=__package__, | ||
| 154 | epilog=( | ||
| 155 | f'%(prog)s {etoolkit.__version__} by Simeon Simeonov ' | ||
| 156 | '(sgs @ LiberaChat)' | ||
| 157 | ), | ||
| 158 | description='The following options are available', | ||
| 159 | ) | ||
| 160 | group = parser.add_mutually_exclusive_group(required=True) | ||
| 161 | group.add_argument( | ||
| 162 | 'instance', | ||
| 163 | metavar='<instance>', | ||
| 164 | nargs='?', | ||
| 165 | type=str, | ||
| 166 | help='The instance to be loaded', | ||
| 167 | ) | ||
| 168 | group.add_argument( | ||
| 169 | '-d', | ||
| 170 | '--decrypt-value', | ||
| 171 | dest='decrypt_value', | ||
| 172 | action='store_true', | ||
| 173 | required=False, | ||
| 174 | help=( | ||
| 175 | 'Prompt for master password & value to decrypt, ' | ||
| 176 | 'display the decrypted value and exit' | ||
| 177 | ), | ||
| 178 | ) | ||
| 179 | group.add_argument( | ||
| 180 | '-e', | ||
| 181 | '--encrypt-value', | ||
| 182 | dest='encrypt_value', | ||
| 183 | action='store_true', | ||
| 184 | required=False, | ||
| 185 | help=( | ||
| 186 | 'Prompt for master password & value to encrypt, ' | ||
| 187 | 'display the encrypted value and exit' | ||
| 188 | ), | ||
| 189 | ) | ||
| 190 | group.add_argument( | ||
| 191 | '-l', | ||
| 192 | '--list', | ||
| 193 | dest='list', | ||
| 194 | action='store_true', | ||
| 195 | required=False, | ||
| 196 | help='List all defined instances', | ||
| 197 | ) | ||
| 198 | group.add_argument( | ||
| 199 | '-p', | ||
| 200 | '--generate-master-password-hash', | ||
| 201 | dest='password_hash', | ||
| 202 | action='store_true', | ||
| 203 | required=False, | ||
| 204 | help='Prompt for master password, display the generated hash and exit', | ||
| 205 | ) | ||
| 206 | parser.add_argument( | ||
| 207 | '-c', | ||
| 208 | '--config-file', | ||
| 209 | metavar='<path>', | ||
| 210 | type=str, | ||
| 211 | default=os.path.expanduser( | ||
| 212 | os.environ.get('ETOOLKIT_CONFIG', '~/.etoolkit.json') | ||
| 213 | ), | ||
| 214 | dest='config_file', | ||
| 215 | help='JSON config file (default: ~/.etoolkit.json)', | ||
| 216 | ) | ||
| 217 | parser.add_argument( | ||
| 218 | '-E', | ||
| 219 | '--echo', | ||
| 220 | dest='echo', | ||
| 221 | action='store_true', | ||
| 222 | help='Display the value to be encrypted (used together with -e)', | ||
| 223 | ) | ||
| 224 | parser.add_argument( | ||
| 225 | '-m', | ||
| 226 | '--multiple-values', | ||
| 227 | dest='multiple_values', | ||
| 228 | action='store_true', | ||
| 229 | help=( | ||
| 230 | 'Prompt for more than one value when ' | ||
| 231 | 'encrypting / decrypting until terminated ' | ||
| 232 | '(Ctrl+C) (used together with -d / -e)' | ||
| 233 | ), | ||
| 234 | ) | ||
| 235 | parser.add_argument( | ||
| 236 | '-P', | ||
| 237 | '--master-password-prompt', | ||
| 238 | dest='master_password_prompt', | ||
| 239 | action='store_true', | ||
| 240 | help=( | ||
| 241 | 'Force prompt for the master password even if the env. variable ' | ||
| 242 | '"ETOOLKIT_MASTER_PASSWORD" is set' | ||
| 243 | ), | ||
| 244 | ) | ||
| 245 | parser.add_argument( | ||
| 246 | '-q', | ||
| 247 | '--no-output', | ||
| 248 | dest='dump_output', | ||
| 249 | action='store_false', | ||
| 250 | default=True, | ||
| 251 | help='Do not print environment variables to stdout', | ||
| 252 | ) | ||
| 253 | parser.add_argument( | ||
| 254 | '-s', | ||
| 255 | '--spawn', | ||
| 256 | metavar='<path>', | ||
| 257 | type=str, | ||
| 258 | default='', | ||
| 259 | dest='spawn', | ||
| 260 | help='Spawn another process than $SHELL', | ||
| 261 | ) | ||
| 262 | parser.add_argument( | ||
| 263 | '-v', | ||
| 264 | '--version', | ||
| 265 | action='version', | ||
| 266 | version=f'%(prog)s {etoolkit.__version__}', | ||
| 267 | help='Display program-version and exit', | ||
| 268 | ) | ||
| 269 | args = parser.parse_args(inargs) | ||
| 270 | try: | ||
| 271 | with io.open(args.config_file, 'r', encoding='utf-8') as fp: | ||
| 272 | config_dict = json.load(fp) | ||
| 273 | except FileNotFoundError as e: | ||
| 274 | # do not raise exception if config-file is missing for: | ||
| 275 | # - decrypting value | ||
| 276 | # - encrypting value | ||
| 277 | # - password hash generation | ||
| 278 | if args.password_hash or args.decrypt_value or args.encrypt_value: | ||
| 279 | logger.warning( | ||
| 280 | "Configuration file %s is missing, although not required " | ||
| 281 | "by the provided parameters", | ||
| 282 | args.config_file, | ||
| 283 | ) | ||
| 284 | config_dict = {} | ||
| 285 | else: | ||
| 286 | logger.error("Configuration file %s is missing", args.config_file) | ||
| 287 | raise SystemExit(errno.EIO) from e | ||
| 288 | except Exception as e: | ||
| 289 | logger.error("Unable to parse %r: %s", args.config_file, e) | ||
| 290 | raise SystemExit(errno.EIO) from e | ||
| 291 | try: | ||
| 292 | if args.decrypt_value: | ||
| 293 | decrypt_value(args, config_dict) | ||
| 294 | sys.exit(0) | ||
| 295 | if args.encrypt_value: | ||
| 296 | encrypt_value(args, config_dict) | ||
| 297 | sys.exit(0) | ||
| 298 | if args.password_hash: | ||
| 299 | master_password = ( | ||
| 300 | etoolkit.EtoolkitInstance.confirm_password_prompt() | ||
| 301 | ) | ||
| 302 | phash = etoolkit.EtoolkitInstance.get_new_password_hash( | ||
| 303 | master_password | ||
| 304 | ) | ||
| 305 | print(f'Master password hash: {phash}') | ||
| 306 | sys.exit(0) | ||
| 307 | if args.list: | ||
| 308 | for instance_name in sorted( | ||
| 309 | config_dict.get('instances', {}).keys() | ||
| 310 | ): | ||
| 311 | print(instance_name) | ||
| 312 | sys.exit(0) | ||
| 313 | |||
| 314 | inst = etoolkit.EtoolkitInstance(args.instance, config_dict) | ||
| 315 | inst.prompt_func = etoolkit.EtoolkitInstance.confirm_password_prompt | ||
| 316 | env = inst.get_environ() | ||
| 317 | |||
| 318 | if args.dump_output: | ||
| 319 | inst.dump_env(env) | ||
| 320 | |||
| 321 | os.environ.update(env) | ||
| 322 | |||
| 323 | if args.spawn: | ||
| 324 | os.system(args.spawn) | ||
| 325 | else: | ||
| 326 | os.system(os.getenv('SHELL', 'bash')) | ||
| 327 | except KeyboardInterrupt: | ||
| 328 | logger.debug('KeyboardInterrupt') | ||
| 329 | print(os.linesep) | ||
| 330 | sys.exit(0) | ||
| 331 | except etoolkit.EtoolkitInstanceError as e: | ||
| 332 | logger.error('EtoolkitInstanceError: %s', e) | ||
| 333 | sys.exit(1) | ||
| 334 | except Exception as e: | ||
| 335 | logger.error('Unexpected exception: %s', e) | ||
| 336 | sys.exit(1) | ||
| 337 | |||
| 338 | |||
| 339 | if __name__ == '__main__': | ||
| 340 | logging.basicConfig(level=DEFAULT_LOG_LEVEL, format=DEFAULT_LOG_FORMAT) | ||
| 341 | 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 @@ | |||
| 1 | # etoolkit | ||
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | ||
| 3 | |||
| 4 | # This program is free software: you can redistribute it and/or modify | ||
| 5 | # it under the terms of the GNU General Public License as published by | ||
| 6 | # the Free Software Foundation, either version 3 of the License, or | ||
| 7 | # (at your option) any later version. | ||
| 8 | |||
| 9 | # This program is distributed in the hope that it will be useful, | ||
| 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 12 | # GNU General Public License for more details. | ||
| 13 | |||
| 14 | # You should have received a copy of the GNU General Public License | ||
| 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| 16 | """The main module of the etoolkit package""" | ||
| 17 | import base64 | ||
| 18 | import getpass | ||
| 19 | import hashlib | ||
| 20 | import os | ||
| 21 | |||
| 22 | from cryptography.exceptions import InvalidTag | ||
| 23 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | ||
| 24 | |||
| 25 | |||
| 26 | class EtoolkitInstanceError(Exception): | ||
| 27 | """EtoolkitInstanceError - Generic exceptions related to instances""" | ||
| 28 | |||
| 29 | |||
| 30 | class EtoolkitInstance: | ||
| 31 | """A basic class representing a single instance""" | ||
| 32 | |||
| 33 | def __init__(self, name: str, data: dict): | ||
| 34 | """ | ||
| 35 | :param name: Instance name | ||
| 36 | :type name: str | ||
| 37 | |||
| 38 | :param data: .etoolkit.json alike dict | ||
| 39 | :type data: dict | ||
| 40 | """ | ||
| 41 | self._name = name | ||
| 42 | self._parent = None | ||
| 43 | self._raw_env_variables = {} | ||
| 44 | self._sensitive_env_variables = [] | ||
| 45 | self._master_password = None | ||
| 46 | self._master_password_hash = None | ||
| 47 | self._prompt_func = None # function to use when prompting for input | ||
| 48 | try: | ||
| 49 | inst_data = data['instances'][name] | ||
| 50 | except KeyError as e: | ||
| 51 | raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e | ||
| 52 | if inst_data.get('ETOOLKIT_PARENT'): | ||
| 53 | self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data) | ||
| 54 | self._raw_env_variables.update(self._parent.raw_env_variables) | ||
| 55 | self._sensitive_env_variables.extend( | ||
| 56 | self._parent.sensitive_env_variables | ||
| 57 | ) | ||
| 58 | if inst_data.get('ETOOLKIT_SENSITIVE'): | ||
| 59 | if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list): | ||
| 60 | raise EtoolkitInstanceError( | ||
| 61 | '"ETOOLKIT_SENSITIVE" must be a list' | ||
| 62 | ) | ||
| 63 | self._sensitive_env_variables.extend( | ||
| 64 | inst_data['ETOOLKIT_SENSITIVE'] | ||
| 65 | ) | ||
| 66 | self._raw_env_variables.update(inst_data) | ||
| 67 | # remove non env. variable data | ||
| 68 | self._raw_env_variables.pop('ETOOLKIT_PARENT', None) | ||
| 69 | self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None) | ||
| 70 | if 'general' in data and 'MASTER_PASSWORD_HASH' in data['general']: | ||
| 71 | self._master_password_hash = data['general'][ | ||
| 72 | 'MASTER_PASSWORD_HASH' | ||
| 73 | ] | ||
| 74 | |||
| 75 | @property | ||
| 76 | def master_password(self) -> str: | ||
| 77 | """master_password-property""" | ||
| 78 | return self._master_password | ||
| 79 | |||
| 80 | @master_password.setter | ||
| 81 | def master_password(self, value): | ||
| 82 | """master_password-property setter""" | ||
| 83 | self._master_password = value | ||
| 84 | |||
| 85 | @property | ||
| 86 | def master_password_hash(self) -> str: | ||
| 87 | """master_password_hash-property""" | ||
| 88 | return self._master_password_hash | ||
| 89 | |||
| 90 | @master_password_hash.setter | ||
| 91 | def master_password_hash(self, value): | ||
| 92 | """master_password_hash-property setter""" | ||
| 93 | self._master_password_hash = value | ||
| 94 | |||
| 95 | @property | ||
| 96 | def name(self) -> str: | ||
| 97 | """name-property""" | ||
| 98 | return self._name | ||
| 99 | |||
| 100 | @property | ||
| 101 | def prompt_func(self): | ||
| 102 | """prompt_func-property""" | ||
| 103 | return self._prompt_func | ||
| 104 | |||
| 105 | @prompt_func.setter | ||
| 106 | def prompt_func(self, value): | ||
| 107 | """prompt_func-property setter""" | ||
| 108 | self._prompt_func = value | ||
| 109 | |||
| 110 | @property | ||
| 111 | def raw_env_variables(self) -> dict: | ||
| 112 | """raw_env_variables-property""" | ||
| 113 | return self._raw_env_variables | ||
| 114 | |||
| 115 | @property | ||
| 116 | def sensitive_env_variables(self) -> list: | ||
| 117 | """sensitive_env_variables-property""" | ||
| 118 | return self._sensitive_env_variables | ||
| 119 | |||
| 120 | @staticmethod | ||
| 121 | def confirm_password_prompt( | ||
| 122 | password_hash: str = None, confirm: bool = True | ||
| 123 | ) -> str: | ||
| 124 | """ | ||
| 125 | Prompts for master password and then for confirmation if `confirm` True | ||
| 126 | |||
| 127 | :param password_hash: Hash to compare with instead of confirm | ||
| 128 | :type password_hash: str | ||
| 129 | |||
| 130 | :param confirm: Confirm the password (and see if there is a match) | ||
| 131 | :type confirm: bool | ||
| 132 | |||
| 133 | :return: Password provided by the user | ||
| 134 | :rtype: str | ||
| 135 | """ | ||
| 136 | try: | ||
| 137 | while True: | ||
| 138 | pass1 = getpass.getpass('Type master password: ') | ||
| 139 | if password_hash: | ||
| 140 | if EtoolkitInstance.password_matches(pass1, password_hash): | ||
| 141 | return pass1 | ||
| 142 | print('Wrong password') | ||
| 143 | continue | ||
| 144 | if confirm: | ||
| 145 | pass2 = getpass.getpass('Confirm master password: ') | ||
| 146 | if not pass1 or pass1 != pass2: | ||
| 147 | print('The passwords are either empty or do not match') | ||
| 148 | continue | ||
| 149 | return pass1.strip() | ||
| 150 | except Exception as e: | ||
| 151 | raise EtoolkitInstanceError('Prompt error') from e | ||
| 152 | |||
| 153 | @staticmethod | ||
| 154 | def decrypt(password: str, edata: str) -> str: | ||
| 155 | """ | ||
| 156 | Decrypts `edata` using `password`. | ||
| 157 | |||
| 158 | `edata` is in the following format: | ||
| 159 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` | ||
| 160 | |||
| 161 | :param password: The password to generate the key with | ||
| 162 | :type password: str | ||
| 163 | |||
| 164 | :param edata: The data to be decrypted | ||
| 165 | :type edata: str | ||
| 166 | |||
| 167 | :return: The output string / decrypted data | ||
| 168 | :rtype: str | ||
| 169 | """ | ||
| 170 | # check for supported versions | ||
| 171 | if not edata.startswith('enc-val$1$'): | ||
| 172 | raise EtoolkitInstanceError( | ||
| 173 | f'Unsupported encryption format: {edata}' | ||
| 174 | ) | ||
| 175 | try: | ||
| 176 | salt, data = [base64.b64decode(t) for t in edata[10:].split('$')] | ||
| 177 | nonce = salt[:12] | ||
| 178 | aesgcm = AESGCM( | ||
| 179 | hashlib.scrypt( | ||
| 180 | password.encode('utf-8'), | ||
| 181 | salt=salt, | ||
| 182 | n=2**14, | ||
| 183 | r=8, | ||
| 184 | p=1, | ||
| 185 | dklen=32, | ||
| 186 | ) | ||
| 187 | ) | ||
| 188 | return aesgcm.decrypt(nonce, data, salt).decode() | ||
| 189 | except InvalidTag as e: | ||
| 190 | raise EtoolkitInstanceError( | ||
| 191 | f'Invalid tag when decrypting: {edata}' | ||
| 192 | ) from e | ||
| 193 | except Exception as e: | ||
| 194 | raise EtoolkitInstanceError( | ||
| 195 | f'Error when decrypting: {edata}' | ||
| 196 | ) from e | ||
| 197 | |||
| 198 | @staticmethod | ||
| 199 | def encrypt(password: str, data: str) -> str: | ||
| 200 | """ | ||
| 201 | Encrypts `data` using `password`. | ||
| 202 | |||
| 203 | The output string is in the following format: | ||
| 204 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` | ||
| 205 | |||
| 206 | :param password: The password to generate the key with | ||
| 207 | :type password: str | ||
| 208 | |||
| 209 | :param data: The data to be encrypted | ||
| 210 | :type data: str | ||
| 211 | |||
| 212 | :return: The output string | ||
| 213 | :rtype: str | ||
| 214 | """ | ||
| 215 | salt = os.urandom(32) | ||
| 216 | aesgcm = AESGCM( | ||
| 217 | hashlib.scrypt( | ||
| 218 | password.encode('utf-8'), | ||
| 219 | salt=salt, | ||
| 220 | n=2**14, | ||
| 221 | r=8, | ||
| 222 | p=1, | ||
| 223 | dklen=32, | ||
| 224 | ) | ||
| 225 | ) | ||
| 226 | nonce = salt[:12] | ||
| 227 | edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt) | ||
| 228 | return ( | ||
| 229 | f'enc-val$1${base64.b64encode(salt).decode()}$' | ||
| 230 | f'{base64.b64encode(edata).decode()}' | ||
| 231 | ) | ||
| 232 | |||
| 233 | @staticmethod | ||
| 234 | def get_new_password_hash(password: str) -> str: | ||
| 235 | """ | ||
| 236 | Returns a complete password hash based on `password` | ||
| 237 | |||
| 238 | This password hash is *not* used as a key when encrypting / decrypting | ||
| 239 | but only for optional check if a correct master password is provided. | ||
| 240 | |||
| 241 | :param password: The plaintext password | ||
| 242 | :type password: str | ||
| 243 | |||
| 244 | :return: The hashed version of `password` | ||
| 245 | :rtype: str | ||
| 246 | """ | ||
| 247 | hash_algo = 'sha256' | ||
| 248 | iterations = 100000 | ||
| 249 | salt = os.urandom(32) | ||
| 250 | key = hashlib.pbkdf2_hmac( | ||
| 251 | hash_algo, password.encode('utf-8'), salt, iterations | ||
| 252 | ) | ||
| 253 | return ( | ||
| 254 | f'pbkdf2_{hash_algo}${iterations}$' | ||
| 255 | f'{base64.b64encode(salt).decode()}$' | ||
| 256 | f'{base64.b64encode(key).decode()}' | ||
| 257 | ) | ||
| 258 | |||
| 259 | @staticmethod | ||
| 260 | def parse_value(value, macros: dict): | ||
| 261 | """ | ||
| 262 | Returns the value with all macros replaced by their values | ||
| 263 | |||
| 264 | If `value` is not of type 'str' simply return `value` | ||
| 265 | |||
| 266 | :param value: A simple value | ||
| 267 | :type value: object | ||
| 268 | |||
| 269 | :param macros: Macros mapping | ||
| 270 | :type macros: dict | ||
| 271 | |||
| 272 | :return: New value with all macros replaced by their values | ||
| 273 | :rtype: object | ||
| 274 | """ | ||
| 275 | if not isinstance(value, str): | ||
| 276 | return value | ||
| 277 | for key, val in macros.items(): | ||
| 278 | value = value.replace(key, val) | ||
| 279 | return value | ||
| 280 | |||
| 281 | @staticmethod | ||
| 282 | def password_matches(password: str, password_hash: str) -> bool: | ||
| 283 | """ | ||
| 284 | Checks `password` agains s stored password hash | ||
| 285 | |||
| 286 | Hash format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 | ||
| 287 | |||
| 288 | :param password: password | ||
| 289 | :type password: str | ||
| 290 | |||
| 291 | :param password_hash: The pbkdf2_hmac password hash | ||
| 292 | :type password_hash: str | ||
| 293 | |||
| 294 | :return: True if the password matches or password_hash is None, | ||
| 295 | :rtype: bool | ||
| 296 | """ | ||
| 297 | if password_hash is None: | ||
| 298 | return True | ||
| 299 | # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 | ||
| 300 | try: | ||
| 301 | tokens = password_hash.split('$') | ||
| 302 | key = hashlib.pbkdf2_hmac( | ||
| 303 | tokens[0].split('_')[1], | ||
| 304 | password.encode('utf-8'), | ||
| 305 | base64.b64decode(tokens[2]), | ||
| 306 | int(tokens[1]), | ||
| 307 | ) | ||
| 308 | return key == base64.b64decode(tokens[3]) | ||
| 309 | except Exception: | ||
| 310 | return False | ||
| 311 | |||
| 312 | def dump_env(self, env: dict): | ||
| 313 | """ | ||
| 314 | Prints an environment dict to stdout. | ||
| 315 | |||
| 316 | :param env: The environment dict | ||
| 317 | :type env: dict | ||
| 318 | """ | ||
| 319 | for key, value in env.items(): | ||
| 320 | if key in self._sensitive_env_variables: | ||
| 321 | print(f'{key}: ***') | ||
| 322 | continue | ||
| 323 | print(f'{key}: {value}') | ||
| 324 | |||
| 325 | def get_environ(self) -> dict: | ||
| 326 | """ | ||
| 327 | Generates a new environ dict | ||
| 328 | |||
| 329 | :return: New environment dict with all macros replaced by their values | ||
| 330 | :rtype: dict | ||
| 331 | """ | ||
| 332 | macros = { | ||
| 333 | '%h': os.path.expanduser('~'), | ||
| 334 | '%i': self.name, | ||
| 335 | # '%f': self.get_full_name(), | ||
| 336 | '%u': getpass.getuser(), | ||
| 337 | } | ||
| 338 | new_env = {} | ||
| 339 | for key, value in sorted( | ||
| 340 | self._raw_env_variables.items(), key=lambda x: x[0] | ||
| 341 | ): | ||
| 342 | if not value: | ||
| 343 | # perhaps unset instead of skipping? | ||
| 344 | continue | ||
| 345 | if isinstance(value, str) and value.startswith('enc-val$1$'): | ||
| 346 | value = self._decrypt_value(value) | ||
| 347 | if isinstance(value, str) and value.endswith(':'): | ||
| 348 | # if 'value' ends with ':', append the existing value of | ||
| 349 | # os.environ[key] after the value of 'value' | ||
| 350 | new_env[key] = self.parse_value( | ||
| 351 | value, macros | ||
| 352 | ) + os.environ.get(key, '') | ||
| 353 | elif isinstance(value, str) and value.startswith(':'): | ||
| 354 | # if 'value' starts with ':', append after the existing value | ||
| 355 | # of os.environ[key] | ||
| 356 | new_env[key] = os.environ.get(key, '') + self.parse_value( | ||
| 357 | value, macros | ||
| 358 | ) | ||
| 359 | else: | ||
| 360 | # completely overwrite the existing value of os.environ[key] | ||
| 361 | new_env[key] = self.parse_value(value, macros) | ||
| 362 | return new_env | ||
| 363 | |||
| 364 | def get_full_name(self, delimiter: str = '') -> str: | ||
| 365 | """ | ||
| 366 | Returns the entire instance inheritence path separated by `delimiter` | ||
| 367 | |||
| 368 | The format is: | ||
| 369 | `grandparent-name`<delimiter>`parent-name`<delimiter>`instance-name` | ||
| 370 | |||
| 371 | :param delimiter: Delimiter to separate parent instance names by | ||
| 372 | :type delimiter: str | ||
| 373 | |||
| 374 | :return: Path in case this instance has parent, instance.name otherwise | ||
| 375 | :rtype: str | ||
| 376 | """ | ||
| 377 | if self._parent is None: | ||
| 378 | return self.name | ||
| 379 | return self._parent.get_full_name(delimiter) + delimiter + self.name | ||
| 380 | |||
| 381 | def _decrypt_value(self, evalue: str) -> str: | ||
| 382 | """ | ||
| 383 | Decrypts an encrypted value using the master password | ||
| 384 | |||
| 385 | The method prompts for the master password when it encounters its | ||
| 386 | first encrypted value since the creation of its EtoolkitInstance | ||
| 387 | object | ||
| 388 | |||
| 389 | `evalue` is in the following format: | ||
| 390 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` | ||
| 391 | |||
| 392 | :param evalue: Encrypted value to be decrypted | ||
| 393 | :type evalue: str | ||
| 394 | |||
| 395 | :return: Decrypted value | ||
| 396 | :rtype: str | ||
| 397 | """ | ||
| 398 | if self._master_password is None: | ||
| 399 | if self._prompt_func is None: | ||
| 400 | raise EtoolkitInstanceError( | ||
| 401 | 'Neither password or prompt function set' | ||
| 402 | ) | ||
| 403 | self._master_password = self._prompt_func( | ||
| 404 | self._master_password_hash, confirm=False | ||
| 405 | ) | ||
| 406 | return EtoolkitInstance.decrypt(self._master_password, evalue) | ||
