diff options
| author | Simeon Simeonov | 2026-05-04 18:38:58 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2026-05-04 18:38:58 +0200 |
| commit | 7fd7db9cd12d59f7c523dc2a1a167f7445e5b2f3 (patch) | |
| tree | b29bec47d27aa927bac8b622bcb04fcfd4691892 /src | |
| parent | 3e4185913b584f5cc9a54f16f3c223861e11ec18 (diff) | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/etoolkit/__init__.py | 2 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 51 | ||||
| -rw-r--r-- | src/etoolkit/etoolkit.py | 141 |
3 files changed, 122 insertions, 72 deletions
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index fc32b61..416be15 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py | |||
| @@ -18,7 +18,7 @@ | |||
| 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError |
| 19 | 19 | ||
| 20 | __author__ = 'Simeon Simeonov' | 20 | __author__ = 'Simeon Simeonov' |
| 21 | __version__ = '2.2.0' | 21 | __version__ = '2.3.0' |
| 22 | __license__ = 'GPL3' | 22 | __license__ = 'GPL3' |
| 23 | 23 | ||
| 24 | 24 | ||
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index cef3d42..33e5741 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py | |||
| @@ -61,10 +61,11 @@ class EtoolkitCLIHandler: | |||
| 61 | self._args = args | 61 | self._args = args |
| 62 | self._config_dict = config_dict | 62 | self._config_dict = config_dict |
| 63 | 63 | ||
| 64 | self._password_hash = None | 64 | self._password_hash: str = '' |
| 65 | |||
| 65 | if 'general' in config_dict: | 66 | if 'general' in config_dict: |
| 66 | self._password_hash = config_dict['general'].get( | 67 | self._password_hash = config_dict['general'].get( |
| 67 | 'MASTER_PASSWORD_HASH' | 68 | 'MASTER_PASSWORD_HASH', '' |
| 68 | ) | 69 | ) |
| 69 | 70 | ||
| 70 | self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 71 | self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| @@ -168,9 +169,28 @@ class EtoolkitCLIHandler: | |||
| 168 | ) | 169 | ) |
| 169 | print(f'Master password hash: {phash}') | 170 | print(f'Master password hash: {phash}') |
| 170 | 171 | ||
| 172 | def handle_args(self) -> None: | ||
| 173 | """Runs the handler""" | ||
| 174 | if self._args.decrypt_value: | ||
| 175 | self.decrypt_value() | ||
| 176 | return | ||
| 177 | if self._args.encrypt_value: | ||
| 178 | self.encrypt_value() | ||
| 179 | return | ||
| 180 | if self._args.password_hash: | ||
| 181 | self.generate_master_password_hash() | ||
| 182 | return | ||
| 183 | if self._args.list: | ||
| 184 | self.list() | ||
| 185 | return | ||
| 186 | if self._args.reencrypt: | ||
| 187 | self.reencrypt() | ||
| 188 | return | ||
| 189 | |||
| 190 | self.load_instance() | ||
| 191 | |||
| 171 | def list(self) -> None: | 192 | def list(self) -> None: |
| 172 | """Lists all instances defined in the config file""" | 193 | """Lists all instances defined in the config file""" |
| 173 | |||
| 174 | for instance_name in sorted( | 194 | for instance_name in sorted( |
| 175 | filter( | 195 | filter( |
| 176 | lambda s: not s.startswith('_'), | 196 | lambda s: not s.startswith('_'), |
| @@ -181,7 +201,6 @@ class EtoolkitCLIHandler: | |||
| 181 | 201 | ||
| 182 | def load_instance(self) -> None: | 202 | def load_instance(self) -> None: |
| 183 | """Loads a single specified instance from the config file""" | 203 | """Loads a single specified instance from the config file""" |
| 184 | |||
| 185 | inst = etoolkit.EtoolkitInstance( | 204 | inst = etoolkit.EtoolkitInstance( |
| 186 | self._args.instance, self._config_dict | 205 | self._args.instance, self._config_dict |
| 187 | ) | 206 | ) |
| @@ -277,9 +296,8 @@ class EtoolkitCLIHandler: | |||
| 277 | ) | 296 | ) |
| 278 | 297 | ||
| 279 | 298 | ||
| 280 | def main(inargs: list = None) -> None: | 299 | def main(inargs: list | None = None) -> None: |
| 281 | """main entry point""" | 300 | """main entry point""" |
| 282 | |||
| 283 | parser = argparse.ArgumentParser( | 301 | parser = argparse.ArgumentParser( |
| 284 | prog=__package__, | 302 | prog=__package__, |
| 285 | epilog=( | 303 | epilog=( |
| @@ -435,26 +453,11 @@ def main(inargs: list = None) -> None: | |||
| 435 | raise SystemExit(errno.EIO) from exp | 453 | raise SystemExit(errno.EIO) from exp |
| 436 | try: | 454 | try: |
| 437 | etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) | 455 | etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) |
| 438 | if args.decrypt_value: | 456 | etoolkit_cli_handler.handle_args() |
| 439 | etoolkit_cli_handler.decrypt_value() | 457 | sys.exit(0) |
| 440 | sys.exit(0) | ||
| 441 | if args.encrypt_value: | ||
| 442 | etoolkit_cli_handler.encrypt_value() | ||
| 443 | sys.exit(0) | ||
| 444 | if args.password_hash: | ||
| 445 | etoolkit_cli_handler.generate_master_password_hash() | ||
| 446 | sys.exit(0) | ||
| 447 | if args.list: | ||
| 448 | etoolkit_cli_handler.list() | ||
| 449 | sys.exit(0) | ||
| 450 | if args.reencrypt: | ||
| 451 | etoolkit_cli_handler.reencrypt() | ||
| 452 | sys.exit(0) | ||
| 453 | |||
| 454 | etoolkit_cli_handler.load_instance() | ||
| 455 | except KeyboardInterrupt: | 458 | except KeyboardInterrupt: |
| 456 | logger.debug('KeyboardInterrupt') | 459 | logger.debug('KeyboardInterrupt') |
| 457 | print(os.linesep) | 460 | print('\n') |
| 458 | sys.exit(0) | 461 | sys.exit(0) |
| 459 | except etoolkit.EtoolkitInstanceError as err: | 462 | except etoolkit.EtoolkitInstanceError as err: |
| 460 | logger.error('EtoolkitInstanceError: %s', err) | 463 | logger.error('EtoolkitInstanceError: %s', err) |
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index f5ce2a6..9369571 100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py | |||
| @@ -19,7 +19,7 @@ import base64 | |||
| 19 | import getpass | 19 | import getpass |
| 20 | import hashlib | 20 | import hashlib |
| 21 | import os | 21 | import os |
| 22 | from collections.abc import Callable | 22 | from typing import Protocol |
| 23 | 23 | ||
| 24 | from cryptography.exceptions import InvalidTag | 24 | from cryptography.exceptions import InvalidTag |
| 25 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | 25 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM |
| @@ -31,6 +31,24 @@ class EtoolkitInstanceError(Exception): | |||
| 31 | """EtoolkitInstanceError - Generic exceptions related to instances""" | 31 | """EtoolkitInstanceError - Generic exceptions related to instances""" |
| 32 | 32 | ||
| 33 | 33 | ||
| 34 | class PromptFuncProtocol(Protocol): | ||
| 35 | """Specialized callable for prompt functions""" | ||
| 36 | |||
| 37 | def __call__(self, password_hash: str = '', confirm: bool = True) -> str: | ||
| 38 | """ | ||
| 39 | Prompts for master password and then for confirmation if `confirm` True | ||
| 40 | |||
| 41 | :param password_hash: Hash to compare with instead of confirm (def. '') | ||
| 42 | :type password_hash: str | ||
| 43 | |||
| 44 | :param confirm: Confirm the password (and see if there is a match) | ||
| 45 | :type confirm: bool | ||
| 46 | |||
| 47 | :return: Password provided by the user | ||
| 48 | :rtype: str | ||
| 49 | """ | ||
| 50 | |||
| 51 | |||
| 34 | class EtoolkitInstance: | 52 | class EtoolkitInstance: |
| 35 | """A basic class representing a single instance""" | 53 | """A basic class representing a single instance""" |
| 36 | 54 | ||
| @@ -47,9 +65,12 @@ class EtoolkitInstance: | |||
| 47 | self._env = None | 65 | self._env = None |
| 48 | self._raw_env_variables = {} | 66 | self._raw_env_variables = {} |
| 49 | self._sensitive_env_variables = [] | 67 | self._sensitive_env_variables = [] |
| 50 | self._master_password = None | 68 | self._master_password: str | None = None |
| 51 | self._master_password_hash = None | 69 | self._master_password_hash: str = '' |
| 52 | self._prompt_func = None # function to use when prompting for input | 70 | |
| 71 | # function to use when prompting for input | ||
| 72 | self._prompt_func: PromptFuncProtocol | None = None | ||
| 73 | |||
| 53 | try: | 74 | try: |
| 54 | self._instance_data = data['instances'][name] | 75 | self._instance_data = data['instances'][name] |
| 55 | except KeyError as err: | 76 | except KeyError as err: |
| @@ -106,12 +127,12 @@ class EtoolkitInstance: | |||
| 106 | return self._name | 127 | return self._name |
| 107 | 128 | ||
| 108 | @property | 129 | @property |
| 109 | def prompt_func(self) -> Callable[[str, bool], str]: | 130 | def prompt_func(self) -> PromptFuncProtocol | None: |
| 110 | """prompt_func-property""" | 131 | """prompt_func-property""" |
| 111 | return self._prompt_func | 132 | return self._prompt_func |
| 112 | 133 | ||
| 113 | @prompt_func.setter | 134 | @prompt_func.setter |
| 114 | def prompt_func(self, value: Callable[[str, bool], str]) -> None: | 135 | def prompt_func(self, value: PromptFuncProtocol) -> None: |
| 115 | """prompt_func-property setter""" | 136 | """prompt_func-property setter""" |
| 116 | self._prompt_func = value | 137 | self._prompt_func = value |
| 117 | if self._parent is not None and self._parent.prompt_func is None: | 138 | if self._parent is not None and self._parent.prompt_func is None: |
| @@ -130,12 +151,12 @@ class EtoolkitInstance: | |||
| 130 | 151 | ||
| 131 | @staticmethod | 152 | @staticmethod |
| 132 | def confirm_password_prompt( | 153 | def confirm_password_prompt( |
| 133 | password_hash: str = None, confirm: bool = True | 154 | password_hash: str = '', confirm: bool = True |
| 134 | ) -> str: | 155 | ) -> str: |
| 135 | """ | 156 | """ |
| 136 | Prompts for master password and then for confirmation if `confirm` True | 157 | Prompts for master password and then for confirmation if `confirm` True |
| 137 | 158 | ||
| 138 | :param password_hash: Hash to compare with instead of confirm | 159 | :param password_hash: Hash to compare with instead of confirm (def. '') |
| 139 | :type password_hash: str | 160 | :type password_hash: str |
| 140 | 161 | ||
| 141 | :param confirm: Confirm the password (and see if there is a match) | 162 | :param confirm: Confirm the password (and see if there is a match) |
| @@ -158,8 +179,8 @@ class EtoolkitInstance: | |||
| 158 | print('The passwords are either empty or do not match') | 179 | print('The passwords are either empty or do not match') |
| 159 | continue | 180 | continue |
| 160 | return pass1.strip() | 181 | return pass1.strip() |
| 161 | except Exception as e: | 182 | except Exception as exp: |
| 162 | raise EtoolkitInstanceError('Prompt error') from e | 183 | raise EtoolkitInstanceError('Prompt error') from exp |
| 163 | 184 | ||
| 164 | @staticmethod | 185 | @staticmethod |
| 165 | def decrypt(password: str, edata: str) -> str: | 186 | def decrypt(password: str, edata: str) -> str: |
| @@ -206,14 +227,14 @@ class EtoolkitInstance: | |||
| 206 | data = data[2 : -int(data[:2].decode())] | 227 | data = data[2 : -int(data[:2].decode())] |
| 207 | 228 | ||
| 208 | return data.decode() | 229 | return data.decode() |
| 209 | except InvalidTag as e: | 230 | except InvalidTag as err: |
| 210 | raise EtoolkitInstanceError( | 231 | raise EtoolkitInstanceError( |
| 211 | f'Invalid tag when decrypting: {edata}' | 232 | f'Invalid tag when decrypting: {edata}' |
| 212 | ) from e | 233 | ) from err |
| 213 | except Exception as e: | 234 | except Exception as exp: |
| 214 | raise EtoolkitInstanceError( | 235 | raise EtoolkitInstanceError( |
| 215 | f'Error when decrypting: {edata}' | 236 | f'Error when decrypting: {edata}' |
| 216 | ) from e | 237 | ) from exp |
| 217 | 238 | ||
| 218 | @staticmethod | 239 | @staticmethod |
| 219 | def encrypt(password: str, data: str) -> str: | 240 | def encrypt(password: str, data: str) -> str: |
| @@ -269,6 +290,28 @@ class EtoolkitInstance: | |||
| 269 | ) | 290 | ) |
| 270 | 291 | ||
| 271 | @staticmethod | 292 | @staticmethod |
| 293 | def get_global_macros() -> dict[str, str]: | ||
| 294 | """ | ||
| 295 | Returns a dict for global macro mapping | ||
| 296 | |||
| 297 | Globals are the same for all instances | ||
| 298 | |||
| 299 | :return: macro: replacement value dict | ||
| 300 | :rtype: dict | ||
| 301 | """ | ||
| 302 | macros = {'%h': os.path.expanduser('~'), '%u': getpass.getuser()} | ||
| 303 | |||
| 304 | # unpack defined environment variables | ||
| 305 | for key, value in os.environ.items(): | ||
| 306 | if not isinstance(value, str): | ||
| 307 | # should not happen | ||
| 308 | continue | ||
| 309 | |||
| 310 | macros[f'%e{{{key}}}'] = value | ||
| 311 | |||
| 312 | return macros | ||
| 313 | |||
| 314 | @staticmethod | ||
| 272 | def get_new_password_hash(password: str) -> str: | 315 | def get_new_password_hash(password: str) -> str: |
| 273 | """ | 316 | """ |
| 274 | Returns a complete password hash based on `password` | 317 | Returns a complete password hash based on `password` |
| @@ -295,23 +338,23 @@ class EtoolkitInstance: | |||
| 295 | ) | 338 | ) |
| 296 | 339 | ||
| 297 | @staticmethod | 340 | @staticmethod |
| 298 | def parse_value(value: object, macros: dict) -> object: | 341 | def parse_value(value: str, macros: dict) -> str: |
| 299 | """ | 342 | """ |
| 300 | Returns the value with all macros replaced by their values | 343 | Returns the value with all macros replaced by their values |
| 301 | 344 | ||
| 302 | If `value` is not of type 'str' simply return `value` | ||
| 303 | |||
| 304 | :param value: A simple value | 345 | :param value: A simple value |
| 305 | :type value: object | 346 | :type value: str |
| 306 | 347 | ||
| 307 | :param macros: Macros mapping | 348 | :param macros: Macros mapping |
| 308 | :type macros: dict | 349 | :type macros: dict |
| 309 | 350 | ||
| 310 | :return: New value with all macros replaced by their values | 351 | :return: New value with all macros replaced by their values |
| 311 | :rtype: object | 352 | :rtype: str |
| 312 | """ | 353 | """ |
| 313 | if not isinstance(value, str): | 354 | if not isinstance(value, str): |
| 314 | return value | 355 | raise EtoolkitInstanceError( |
| 356 | "Environment variable value not of the type 'str' detected" | ||
| 357 | ) | ||
| 315 | for key, val in macros.items(): | 358 | for key, val in macros.items(): |
| 316 | value = value.replace(key, val) | 359 | value = value.replace(key, val) |
| 317 | return value | 360 | return value |
| @@ -332,7 +375,7 @@ class EtoolkitInstance: | |||
| 332 | :return: True if the password matches or password_hash is None, | 375 | :return: True if the password matches or password_hash is None, |
| 333 | :rtype: bool | 376 | :rtype: bool |
| 334 | """ | 377 | """ |
| 335 | if password_hash is None: | 378 | if not password_hash: |
| 336 | return True | 379 | return True |
| 337 | # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 | 380 | # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64 |
| 338 | try: | 381 | try: |
| @@ -401,11 +444,9 @@ class EtoolkitInstance: | |||
| 401 | if self._env is not None: | 444 | if self._env is not None: |
| 402 | return self._env | 445 | return self._env |
| 403 | 446 | ||
| 404 | macros = { | 447 | macros = self.get_global_macros() |
| 405 | '%h': os.path.expanduser('~'), | 448 | macros['%i'] = self.name |
| 406 | '%i': self.name, | 449 | |
| 407 | '%u': getpass.getuser(), | ||
| 408 | } | ||
| 409 | new_env = {} | 450 | new_env = {} |
| 410 | for key, value in sorted( | 451 | for key, value in sorted( |
| 411 | self._raw_env_variables.items(), key=lambda x: x[0] | 452 | self._raw_env_variables.items(), key=lambda x: x[0] |
| @@ -413,14 +454,12 @@ class EtoolkitInstance: | |||
| 413 | if not value: | 454 | if not value: |
| 414 | # perhaps unset instead of skipping? | 455 | # perhaps unset instead of skipping? |
| 415 | continue | 456 | continue |
| 416 | if isinstance(value, str) and '%e' in value: | 457 | macros['%e'] = os.environ.get(key, '') |
| 417 | macros['%e'] = os.environ.get(key, '') | 458 | macros['%p'] = ( |
| 418 | if isinstance(value, str) and '%p' in value: | 459 | self._parent.get_environ().get(key, '') |
| 419 | macros['%p'] = ( | 460 | if self._parent is not None |
| 420 | self._parent.get_environ().get(key, '') | 461 | else '' |
| 421 | if self._parent is not None | 462 | ) |
| 422 | else '' | ||
| 423 | ) | ||
| 424 | if isinstance(value, str) and value.startswith('enc-val$'): | 463 | if isinstance(value, str) and value.startswith('enc-val$'): |
| 425 | value = self._decrypt_value(value) | 464 | value = self._decrypt_value(value) |
| 426 | if key not in self._sensitive_env_variables: | 465 | if key not in self._sensitive_env_variables: |
| @@ -469,7 +508,7 @@ class EtoolkitInstance: | |||
| 469 | return self._parent.get_full_name(delimiter) + delimiter + self.name | 508 | return self._parent.get_full_name(delimiter) + delimiter + self.name |
| 470 | 509 | ||
| 471 | def get_reencrypted_instance_data( | 510 | def get_reencrypted_instance_data( |
| 472 | self, new_password: str, password: str = None | 511 | self, new_password: str, password: str | None = None |
| 473 | ) -> dict: | 512 | ) -> dict: |
| 474 | """ | 513 | """ |
| 475 | Returns new instance data (dict) containing new encrypted values | 514 | Returns new instance data (dict) containing new encrypted values |
| @@ -493,16 +532,18 @@ class EtoolkitInstance: | |||
| 493 | if password is None: | 532 | if password is None: |
| 494 | password = self._master_password | 533 | password = self._master_password |
| 495 | 534 | ||
| 496 | if password is None and self._prompt_func is None: | 535 | if password is None: |
| 497 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 536 | if self._prompt_func is None: |
| 498 | if password is None: | 537 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| 499 | raise EtoolkitInstanceError( | 538 | else: |
| 500 | 'Neither password or prompt function set' | 539 | password = self._prompt_func( |
| 540 | self._master_password_hash, confirm=False | ||
| 501 | ) | 541 | ) |
| 502 | 542 | ||
| 503 | if password is None: | 543 | if password is None: |
| 504 | password = self._prompt_func( | 544 | # the password is still not set |
| 505 | self._master_password_hash, confirm=False | 545 | raise EtoolkitInstanceError( |
| 546 | 'Neither password or prompt function set' | ||
| 506 | ) | 547 | ) |
| 507 | 548 | ||
| 508 | new_data = dict(self._instance_data) | 549 | new_data = dict(self._instance_data) |
| @@ -529,7 +570,10 @@ class EtoolkitInstance: | |||
| 529 | :return: Decrypted value | 570 | :return: Decrypted value |
| 530 | :rtype: str | 571 | :rtype: str |
| 531 | """ | 572 | """ |
| 532 | if self._master_password is None: | 573 | if self._master_password is not None: |
| 574 | master_password = self._master_password | ||
| 575 | else: | ||
| 576 | # master password not set for this instance | ||
| 533 | if self._prompt_func is None: | 577 | if self._prompt_func is None: |
| 534 | if ( | 578 | if ( |
| 535 | mp_from_env := os.environ.get('ETOOLKIT_MASTER_PASSWORD') | 579 | mp_from_env := os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| @@ -537,10 +581,13 @@ class EtoolkitInstance: | |||
| 537 | raise EtoolkitInstanceError( | 581 | raise EtoolkitInstanceError( |
| 538 | 'Neither password or prompt function set' | 582 | 'Neither password or prompt function set' |
| 539 | ) | 583 | ) |
| 540 | self.master_password = mp_from_env | 584 | master_password = mp_from_env |
| 541 | else: | 585 | else: |
| 542 | # use master_password setter in order to propagate to parent | 586 | master_password = self._prompt_func( |
| 543 | self.master_password = self._prompt_func( | ||
| 544 | self._master_password_hash, confirm=False | 587 | self._master_password_hash, confirm=False |
| 545 | ) | 588 | ) |
| 546 | return self.decrypt(self._master_password, evalue) | 589 | |
| 590 | # use master_password setter in order to propagate to parent | ||
| 591 | self.master_password = master_password | ||
| 592 | |||
| 593 | return self.decrypt(master_password, evalue) | ||
