summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/etoolkit/__init__.py2
-rw-r--r--src/etoolkit/__main__.py51
-rw-r--r--src/etoolkit/etoolkit.py141
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 @@
18from .etoolkit import EtoolkitInstance, EtoolkitInstanceError 18from .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
280def main(inargs: list = None) -> None: 299def 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
19import getpass 19import getpass
20import hashlib 20import hashlib
21import os 21import os
22from collections.abc import Callable 22from typing import Protocol
23 23
24from cryptography.exceptions import InvalidTag 24from cryptography.exceptions import InvalidTag
25from cryptography.hazmat.primitives.ciphers.aead import AESGCM 25from 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
34class 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
34class EtoolkitInstance: 52class 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)