diff options
| author | Simeon Simeonov | 2024-05-13 21:52:13 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2024-05-13 21:52:13 +0200 |
| commit | 08a3280b062af83ee50fa139d7827d954907886e (patch) | |
| tree | 8e1d3f3117d05b3ad779d070ec30ddbbe242e62b /src | |
| parent | bb9a844e22134a2537652ea14f93e82acb4ee380 (diff) | |
Implement re-encryption support2.0.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/etoolkit/__init__.py | 2 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 371 | ||||
| -rw-r--r-- | src/etoolkit/etoolkit.py | 110 |
3 files changed, 336 insertions, 147 deletions
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index 0ef5957..711c6ae 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__ = '1.3.0' | 21 | __version__ = '2.0.0' |
| 22 | __license__ = 'GPL3' | 22 | __license__ = 'GPL3' |
| 23 | 23 | ||
| 24 | 24 | ||
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index 8b5bd55..a2f2f20 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py | |||
| @@ -42,117 +42,244 @@ DEFAULT_LOG_LEVEL = logging.WARNING | |||
| 42 | logger = logging.getLogger(__name__) | 42 | logger = logging.getLogger(__name__) |
| 43 | 43 | ||
| 44 | 44 | ||
| 45 | def decrypt_value(args: argparse.Namespace, config: dict): | 45 | class EtoolkitCLIHandler: |
| 46 | """ | 46 | """ |
| 47 | Interactive function for decrypting value(s) | 47 | Helper class used for handleing the growing amount of arguments |
| 48 | 48 | ||
| 49 | Prompts for master key password and then prompts for a value to decrypt | 49 | This class consists mostly of interactive methods and is not intended as |
| 50 | a part of the etoolkit API | ||
| 51 | """ | ||
| 50 | 52 | ||
| 51 | The decrypted value is printed to stdout | 53 | def __init__(self, args: argparse.Namespace, config_dict: dict): |
| 54 | """ | ||
| 55 | :param args: The parsed argparse arguments sent by the caller | ||
| 56 | :type args: argparse.Namespace | ||
| 52 | 57 | ||
| 53 | :param args: The arguments sent by the caller | 58 | :param config_dict: The config file structure |
| 54 | :type args: arparse.Namespace | 59 | :type config_dict: dict |
| 60 | """ | ||
| 61 | self._args = args | ||
| 62 | self._config_dict = config_dict | ||
| 55 | 63 | ||
| 56 | :param config: The config dict sent by the caller | 64 | self._password_hash = None |
| 57 | :type config: dict | 65 | if 'general' in config_dict: |
| 58 | """ | 66 | self._password_hash = config_dict['general'].get( |
| 59 | password_hash = None | 67 | 'MASTER_PASSWORD_HASH' |
| 60 | pipe_input = None | 68 | ) |
| 61 | if not os.isatty(sys.stdin.fileno()): | ||
| 62 | pipe_input = sys.stdin.read().strip() | ||
| 63 | if 'general' in config: | ||
| 64 | password_hash = config['general'].get('MASTER_PASSWORD_HASH') | ||
| 65 | 69 | ||
| 66 | if ( | 70 | self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') |
| 67 | args.master_password_prompt | ||
| 68 | or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None | ||
| 69 | ): | ||
| 70 | password = etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 71 | password_hash, False | ||
| 72 | ) | ||
| 73 | else: | ||
| 74 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | ||
| 75 | 71 | ||
| 76 | if pipe_input: | 72 | def decrypt_value(self): |
| 77 | # the input came from stdin. No need to prompt | 73 | """ |
| 78 | print( | 74 | Interactive method for decrypting value(s) |
| 79 | 'Decrypted value: ' | 75 | |
| 80 | f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' | 76 | Prompts for master key password and then prompts for a value to decrypt |
| 81 | ) | 77 | |
| 82 | return | 78 | The decrypted value is printed to stdout |
| 83 | while True: | 79 | """ |
| 84 | try: | 80 | pipe_input = None |
| 85 | value = input('Value: ') | 81 | if not os.isatty(sys.stdin.fileno()): |
| 82 | pipe_input = sys.stdin.read().strip() | ||
| 83 | |||
| 84 | if ( | ||
| 85 | self._args.master_password_prompt | ||
| 86 | or self._password_from_env is None | ||
| 87 | ): | ||
| 88 | password = self._password_prompt() | ||
| 89 | else: | ||
| 90 | password = self._password_from_env | ||
| 91 | |||
| 92 | if pipe_input: | ||
| 93 | # the input came from stdin. No need to prompt | ||
| 86 | print( | 94 | print( |
| 87 | 'Decrypted value: ' | 95 | 'Decrypted value: ' |
| 88 | f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' | 96 | f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' |
| 89 | ) | 97 | ) |
| 90 | if not args.multiple_values: | 98 | return |
| 99 | while True: | ||
| 100 | try: | ||
| 101 | value = input('Value: ') | ||
| 102 | print( | ||
| 103 | 'Decrypted value: ' | ||
| 104 | f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' | ||
| 105 | ) | ||
| 106 | if not self._args.multiple_values: | ||
| 107 | break | ||
| 108 | except KeyboardInterrupt: | ||
| 109 | print(os.linesep) | ||
| 91 | break | 110 | break |
| 92 | except KeyboardInterrupt: | 111 | return |
| 93 | print(os.linesep) | ||
| 94 | break | ||
| 95 | return | ||
| 96 | 112 | ||
| 113 | def encrypt_value(self): | ||
| 114 | """ | ||
| 115 | Interactive method for encrypting value(s) | ||
| 97 | 116 | ||
| 98 | def encrypt_value(args: argparse.Namespace, config: dict): | 117 | Prompts for master key password and then prompts for a value to encrypt |
| 99 | """ | ||
| 100 | Interactive function for encrypting value(s) | ||
| 101 | 118 | ||
| 102 | Prompts for master key password and then prompts for a value to encrypt | 119 | The encrypted value is printed to stdout |
| 120 | """ | ||
| 121 | pipe_input = None | ||
| 122 | if not os.isatty(sys.stdin.fileno()): | ||
| 123 | pipe_input = sys.stdin.read().strip() | ||
| 103 | 124 | ||
| 104 | The encrypted value is printed to stdout | 125 | if ( |
| 126 | self._args.master_password_prompt | ||
| 127 | or self._password_from_env is None | ||
| 128 | ): | ||
| 129 | password = self._password_prompt_confirm() | ||
| 130 | else: | ||
| 131 | password = self._password_from_env | ||
| 105 | 132 | ||
| 106 | :param args: The arguments sent by the caller | 133 | if pipe_input: |
| 107 | :type args: arparse.Namespace | 134 | # the input came from stdin. No need to prompt |
| 135 | print( | ||
| 136 | 'Encrypted value: ' | ||
| 137 | f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' | ||
| 138 | ) | ||
| 139 | return | ||
| 108 | 140 | ||
| 109 | :param config: The config dict sent by the caller | 141 | while True: |
| 110 | :type config: dict | 142 | try: |
| 111 | """ | 143 | if self._args.echo: |
| 112 | password_hash = None | 144 | value = input('Value: ') |
| 113 | pipe_input = None | 145 | else: |
| 114 | if not os.isatty(sys.stdin.fileno()): | 146 | value = getpass.getpass('Value: ') |
| 115 | pipe_input = sys.stdin.read().strip() | 147 | print( |
| 116 | if 'general' in config: | 148 | 'Encrypted value: ' |
| 117 | password_hash = config['general'].get('MASTER_PASSWORD_HASH') | 149 | f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' |
| 150 | ) | ||
| 151 | if not self._args.multiple_values: | ||
| 152 | break | ||
| 153 | except KeyboardInterrupt: | ||
| 154 | print(os.linesep) | ||
| 155 | break | ||
| 156 | return | ||
| 157 | |||
| 158 | def generate_master_password_hash(self): | ||
| 159 | """ | ||
| 160 | Interactive method for generating password hash | ||
| 161 | |||
| 162 | Prompts for master key password and then for confirmation | ||
| 118 | 163 | ||
| 119 | if ( | 164 | The generated hash is printed to stdout |
| 120 | args.master_password_prompt | 165 | """ |
| 121 | or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None | 166 | phash = etoolkit.EtoolkitInstance.get_new_password_hash( |
| 122 | ): | 167 | etoolkit.EtoolkitInstance.confirm_password_prompt() |
| 123 | password = etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 124 | password_hash | ||
| 125 | ) | 168 | ) |
| 126 | else: | 169 | print(f'Master password hash: {phash}') |
| 127 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | ||
| 128 | 170 | ||
| 129 | if pipe_input: | 171 | def list(self): |
| 130 | # the input came from stdin. No need to prompt | 172 | """Lists all instances defined in the config file""" |
| 131 | print( | 173 | |
| 132 | 'Encrypted value: ' | 174 | for instance_name in sorted( |
| 133 | f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' | 175 | filter( |
| 176 | lambda s: not s.startswith('_'), | ||
| 177 | self._config_dict.get('instances', {}).keys(), | ||
| 178 | ) | ||
| 179 | ): | ||
| 180 | print(instance_name) | ||
| 181 | |||
| 182 | def load_instance(self): | ||
| 183 | """Loads a single specified instance from the config file""" | ||
| 184 | |||
| 185 | inst = etoolkit.EtoolkitInstance( | ||
| 186 | self._args.instance, self._config_dict | ||
| 134 | ) | 187 | ) |
| 135 | return | 188 | |
| 136 | while True: | 189 | if ( |
| 137 | try: | 190 | self._args.master_password_prompt |
| 138 | if args.echo: | 191 | or self._password_from_env is None |
| 139 | value = input('Value: ') | 192 | ): |
| 140 | else: | 193 | inst.prompt_func = ( |
| 141 | value = getpass.getpass('Value: ') | 194 | etoolkit.EtoolkitInstance.confirm_password_prompt |
| 195 | ) | ||
| 196 | |||
| 197 | env = inst.get_environ() | ||
| 198 | |||
| 199 | if self._args.dump_output: | ||
| 200 | print(inst.env_to_str(env)) | ||
| 201 | |||
| 202 | os.environ.update(env) | ||
| 203 | |||
| 204 | if self._args.spawn: | ||
| 205 | subprocess.run(self._args.spawn.split(), check=False) | ||
| 206 | else: | ||
| 207 | subprocess.run( | ||
| 208 | os.environ.get('SHELL', 'bash').split(), check=False | ||
| 209 | ) | ||
| 210 | |||
| 211 | def reencrypt(self): | ||
| 212 | """ | ||
| 213 | Interactive method that prints new configuration data (JSON) to stdout | ||
| 214 | |||
| 215 | Prompts for master key password and then for a new password, | ||
| 216 | which may be the same as the current password | ||
| 217 | |||
| 218 | All existing encrypted values are decrypted using the current password | ||
| 219 | and then encrypted with the new password | ||
| 220 | """ | ||
| 221 | print('(Current password) ', end='', flush=True) | ||
| 222 | if ( | ||
| 223 | self._args.master_password_prompt | ||
| 224 | or self._password_from_env is None | ||
| 225 | ): | ||
| 226 | password = self._password_prompt() | ||
| 227 | else: | ||
| 228 | password = self._password_from_env | ||
| 229 | |||
| 230 | print('(New password) ', end='', flush=True) | ||
| 231 | new_password = etoolkit.EtoolkitInstance.confirm_password_prompt() | ||
| 232 | |||
| 233 | if self._args.reencrypt != 'all': | ||
| 234 | # re-encrypt a single instance | ||
| 235 | inst = etoolkit.EtoolkitInstance( | ||
| 236 | self._args.reencrypt, self._config_dict | ||
| 237 | ) | ||
| 142 | print( | 238 | print( |
| 143 | 'Encrypted value: ' | 239 | json.dumps( |
| 144 | f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' | 240 | inst.get_reencrypted_instance_data(new_password, password), |
| 241 | indent=4, | ||
| 242 | ) | ||
| 145 | ) | 243 | ) |
| 146 | if not args.multiple_values: | 244 | return |
| 147 | break | 245 | |
| 148 | except KeyboardInterrupt: | 246 | # re-encrypt all |
| 149 | print(os.linesep) | 247 | new_config_dict = dict(self._config_dict) |
| 150 | break | 248 | if ( |
| 151 | return | 249 | 'general' in new_config_dict |
| 250 | and 'MASTER_PASSWORD_HASH' in new_config_dict['general'] | ||
| 251 | ): | ||
| 252 | new_config_dict['general']['MASTER_PASSWORD_HASH'] = ( | ||
| 253 | etoolkit.EtoolkitInstance.get_new_password_hash(new_password) | ||
| 254 | ) | ||
| 255 | |||
| 256 | for instance_name in self._config_dict['instances']: | ||
| 257 | inst = etoolkit.EtoolkitInstance(instance_name, self._config_dict) | ||
| 258 | new_config_dict['instances'][instance_name] = ( | ||
| 259 | inst.get_reencrypted_instance_data(new_password, password) | ||
| 260 | ) | ||
| 261 | print(json.dumps(new_config_dict, indent=4)) | ||
| 262 | |||
| 263 | def _password_prompt(self) -> str: | ||
| 264 | """ | ||
| 265 | Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=False) | ||
| 266 | """ | ||
| 267 | return etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 268 | self._password_hash, False | ||
| 269 | ) | ||
| 270 | |||
| 271 | def _password_prompt_confirm(self) -> str: | ||
| 272 | """ | ||
| 273 | Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=True) | ||
| 274 | """ | ||
| 275 | return etoolkit.EtoolkitInstance.confirm_password_prompt( | ||
| 276 | self._password_hash | ||
| 277 | ) | ||
| 152 | 278 | ||
| 153 | 279 | ||
| 154 | def main(inargs=None): | 280 | def main(inargs=None): |
| 155 | """main entry point""" | 281 | """main entry point""" |
| 282 | |||
| 156 | parser = argparse.ArgumentParser( | 283 | parser = argparse.ArgumentParser( |
| 157 | prog=__package__, | 284 | prog=__package__, |
| 158 | epilog=( | 285 | epilog=( |
| @@ -207,6 +334,20 @@ def main(inargs=None): | |||
| 207 | required=False, | 334 | required=False, |
| 208 | help='Prompt for master password, display the generated hash and exit', | 335 | help='Prompt for master password, display the generated hash and exit', |
| 209 | ) | 336 | ) |
| 337 | group.add_argument( | ||
| 338 | '-r', | ||
| 339 | '--reencrypt', | ||
| 340 | metavar='<instance | all>', | ||
| 341 | type=str, | ||
| 342 | default='', | ||
| 343 | dest='reencrypt', | ||
| 344 | required=False, | ||
| 345 | help=( | ||
| 346 | 'Prompt for current master password, new master password and ' | ||
| 347 | 're-encrypt either all encrypted values or only those for a ' | ||
| 348 | 'given instance' | ||
| 349 | ), | ||
| 350 | ) | ||
| 210 | parser.add_argument( | 351 | parser.add_argument( |
| 211 | '-c', | 352 | '-c', |
| 212 | '--config-file', | 353 | '--config-file', |
| @@ -274,7 +415,7 @@ def main(inargs=None): | |||
| 274 | try: | 415 | try: |
| 275 | with io.open(args.config_file, encoding='utf-8') as fp: | 416 | with io.open(args.config_file, encoding='utf-8') as fp: |
| 276 | config_dict = json.load(fp) | 417 | config_dict = json.load(fp) |
| 277 | except FileNotFoundError as e: | 418 | except FileNotFoundError as err: |
| 278 | # do not raise exception if config-file is missing for: | 419 | # do not raise exception if config-file is missing for: |
| 279 | # - decrypting value | 420 | # - decrypting value |
| 280 | # - encrypting value | 421 | # - encrypting value |
| @@ -288,66 +429,38 @@ def main(inargs=None): | |||
| 288 | config_dict = {} | 429 | config_dict = {} |
| 289 | else: | 430 | else: |
| 290 | logger.error('Configuration file %s is missing', args.config_file) | 431 | logger.error('Configuration file %s is missing', args.config_file) |
| 291 | raise SystemExit(errno.EIO) from e | 432 | raise SystemExit(errno.EIO) from err |
| 292 | except Exception as e: | 433 | except Exception as exp: |
| 293 | logger.exception('Unable to parse %r', args.config_file) | 434 | logger.exception('Unable to parse %r', args.config_file) |
| 294 | raise SystemExit(errno.EIO) from e | 435 | raise SystemExit(errno.EIO) from exp |
| 295 | try: | 436 | try: |
| 437 | etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) | ||
| 296 | if args.decrypt_value: | 438 | if args.decrypt_value: |
| 297 | decrypt_value(args, config_dict) | 439 | etoolkit_cli_handler.decrypt_value() |
| 298 | sys.exit(0) | 440 | sys.exit(0) |
| 299 | if args.encrypt_value: | 441 | if args.encrypt_value: |
| 300 | encrypt_value(args, config_dict) | 442 | etoolkit_cli_handler.encrypt_value() |
| 301 | sys.exit(0) | 443 | sys.exit(0) |
| 302 | if args.password_hash: | 444 | if args.password_hash: |
| 303 | master_password = ( | 445 | etoolkit_cli_handler.generate_master_password_hash() |
| 304 | etoolkit.EtoolkitInstance.confirm_password_prompt() | ||
| 305 | ) | ||
| 306 | phash = etoolkit.EtoolkitInstance.get_new_password_hash( | ||
| 307 | master_password | ||
| 308 | ) | ||
| 309 | print(f'Master password hash: {phash}') | ||
| 310 | sys.exit(0) | 446 | sys.exit(0) |
| 311 | if args.list: | 447 | if args.list: |
| 312 | for instance_name in sorted( | 448 | etoolkit_cli_handler.list() |
| 313 | filter( | 449 | sys.exit(0) |
| 314 | lambda s: not s.startswith('_'), | 450 | if args.reencrypt: |
| 315 | config_dict.get('instances', {}).keys(), | 451 | etoolkit_cli_handler.reencrypt() |
| 316 | ) | ||
| 317 | ): | ||
| 318 | print(instance_name) | ||
| 319 | sys.exit(0) | 452 | sys.exit(0) |
| 320 | 453 | ||
| 321 | inst = etoolkit.EtoolkitInstance(args.instance, config_dict) | 454 | etoolkit_cli_handler.load_instance() |
| 322 | if ( | ||
| 323 | args.master_password_prompt | ||
| 324 | or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None | ||
| 325 | ): | ||
| 326 | inst.prompt_func = ( | ||
| 327 | etoolkit.EtoolkitInstance.confirm_password_prompt | ||
| 328 | ) | ||
| 329 | env = inst.get_environ() | ||
| 330 | |||
| 331 | if args.dump_output: | ||
| 332 | inst.dump_env(env) | ||
| 333 | |||
| 334 | os.environ.update(env) | ||
| 335 | |||
| 336 | if args.spawn: | ||
| 337 | subprocess.run(args.spawn.split(), check=False) | ||
| 338 | else: | ||
| 339 | subprocess.run( | ||
| 340 | os.environ.get('SHELL', 'bash').split(), check=False | ||
| 341 | ) | ||
| 342 | except KeyboardInterrupt: | 455 | except KeyboardInterrupt: |
| 343 | logger.debug('KeyboardInterrupt') | 456 | logger.debug('KeyboardInterrupt') |
| 344 | print(os.linesep) | 457 | print(os.linesep) |
| 345 | sys.exit(0) | 458 | sys.exit(0) |
| 346 | except etoolkit.EtoolkitInstanceError as e: | 459 | except etoolkit.EtoolkitInstanceError as err: |
| 347 | logger.error('EtoolkitInstanceError: %s', e) | 460 | logger.error('EtoolkitInstanceError: %s', err) |
| 348 | sys.exit(1) | 461 | sys.exit(1) |
| 349 | except subprocess.CalledProcessError as e: | 462 | except subprocess.CalledProcessError as err: |
| 350 | logger.error('Unable to spawn shell process: %s', e) | 463 | logger.error('Unable to spawn shell process: %s', err) |
| 351 | sys.exit(1) | 464 | sys.exit(1) |
| 352 | except Exception: | 465 | except Exception: |
| 353 | logger.exception('Unexpected exception') | 466 | logger.exception('Unexpected exception') |
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index a2e0d7a..9a1b47e 100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py | |||
| @@ -23,7 +23,6 @@ import os | |||
| 23 | from cryptography.exceptions import InvalidTag | 23 | from cryptography.exceptions import InvalidTag |
| 24 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | 24 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM |
| 25 | 25 | ||
| 26 | |||
| 27 | MIN_ENCRYPTED_VALUE_LENGTH = 32 | 26 | MIN_ENCRYPTED_VALUE_LENGTH = 32 |
| 28 | 27 | ||
| 29 | 28 | ||
| @@ -50,24 +49,26 @@ class EtoolkitInstance: | |||
| 50 | self._master_password_hash = None | 49 | self._master_password_hash = None |
| 51 | self._prompt_func = None # function to use when prompting for input | 50 | self._prompt_func = None # function to use when prompting for input |
| 52 | try: | 51 | try: |
| 53 | inst_data = data['instances'][name] | 52 | self._instance_data = data['instances'][name] |
| 54 | except KeyError as e: | 53 | except KeyError as err: |
| 55 | raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e | 54 | raise EtoolkitInstanceError(f'Unknown instance "{name}"') from err |
| 56 | if inst_data.get('ETOOLKIT_PARENT'): | 55 | if self._instance_data.get('ETOOLKIT_PARENT'): |
| 57 | self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data) | 56 | self._parent = EtoolkitInstance( |
| 57 | self._instance_data['ETOOLKIT_PARENT'], data | ||
| 58 | ) | ||
| 58 | self._raw_env_variables.update(self._parent.raw_env_variables) | 59 | self._raw_env_variables.update(self._parent.raw_env_variables) |
| 59 | self._sensitive_env_variables.extend( | 60 | self._sensitive_env_variables.extend( |
| 60 | self._parent.sensitive_env_variables | 61 | self._parent.sensitive_env_variables |
| 61 | ) | 62 | ) |
| 62 | if inst_data.get('ETOOLKIT_SENSITIVE'): | 63 | if self._instance_data.get('ETOOLKIT_SENSITIVE'): |
| 63 | if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list): | 64 | if not isinstance(self._instance_data['ETOOLKIT_SENSITIVE'], list): |
| 64 | raise EtoolkitInstanceError( | 65 | raise EtoolkitInstanceError( |
| 65 | '"ETOOLKIT_SENSITIVE" must be a list' | 66 | '"ETOOLKIT_SENSITIVE" must be a list' |
| 66 | ) | 67 | ) |
| 67 | self._sensitive_env_variables.extend( | 68 | self._sensitive_env_variables.extend( |
| 68 | inst_data['ETOOLKIT_SENSITIVE'] | 69 | self._instance_data['ETOOLKIT_SENSITIVE'] |
| 69 | ) | 70 | ) |
| 70 | self._raw_env_variables.update(inst_data) | 71 | self._raw_env_variables.update(self._instance_data) |
| 71 | # remove non env. variable data | 72 | # remove non env. variable data |
| 72 | self._raw_env_variables.pop('ETOOLKIT_PARENT', None) | 73 | self._raw_env_variables.pop('ETOOLKIT_PARENT', None) |
| 73 | self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None) | 74 | self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None) |
| @@ -200,7 +201,7 @@ class EtoolkitInstance: | |||
| 200 | # padding_length_bytes(2 bytes) data padding (between 0 and 32) | 201 | # padding_length_bytes(2 bytes) data padding (between 0 and 32) |
| 201 | 202 | ||
| 202 | # extract padding_length_bytes | 203 | # extract padding_length_bytes |
| 203 | if data[:2] == b'--': | 204 | if data[:2] == b'-1' or data[:2] == b'--': |
| 204 | data = data[2:] | 205 | data = data[2:] |
| 205 | else: | 206 | else: |
| 206 | data = data[2 : -int(data[:2].decode())] | 207 | data = data[2 : -int(data[:2].decode())] |
| @@ -259,7 +260,7 @@ class EtoolkitInstance: | |||
| 259 | ) | 260 | ) |
| 260 | ) | 261 | ) |
| 261 | nonce = salt[:12] | 262 | nonce = salt[:12] |
| 262 | padding_length_bytes = b'--' # no padding used 2 bytes "sign" | 263 | padding_length_bytes = b'-1' # no padding used 2 bytes "sign" |
| 263 | edata = aesgcm.encrypt( | 264 | edata = aesgcm.encrypt( |
| 264 | nonce, padding_length_bytes + data_bytes, salt | 265 | nonce, padding_length_bytes + data_bytes, salt |
| 265 | ) | 266 | ) |
| @@ -347,18 +348,49 @@ class EtoolkitInstance: | |||
| 347 | except Exception: | 348 | except Exception: |
| 348 | return False | 349 | return False |
| 349 | 350 | ||
| 350 | def dump_env(self, env: dict): | 351 | @staticmethod |
| 352 | def reencrypt(password: str, new_password: str, edata: str) -> str: | ||
| 353 | """ | ||
| 354 | Re-encrypts `edata` using `password` and `new_password`. | ||
| 355 | |||
| 356 | Version 2 of the etoolkit encryption format | ||
| 357 | |||
| 358 | `edata` is in the following format: | ||
| 359 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` | ||
| 360 | |||
| 361 | :param password: The password to decrypt `edata` with | ||
| 362 | :type password: str | ||
| 363 | |||
| 364 | :param new_password: The password to re-encrypt the plain-text with | ||
| 365 | :type new_password: str | ||
| 366 | |||
| 367 | :param edata: The data to be re-encrypted | ||
| 368 | :type edata: str | ||
| 369 | |||
| 370 | :return: The new encrypted string string | ||
| 371 | :rtype: str | ||
| 372 | """ | ||
| 373 | return EtoolkitInstance.encrypt( | ||
| 374 | new_password, EtoolkitInstance.decrypt(password, edata) | ||
| 375 | ) | ||
| 376 | |||
| 377 | def env_to_str(self, env: dict) -> str: | ||
| 351 | """ | 378 | """ |
| 352 | Prints an environment dict to stdout. | 379 | Returns a printable str. representation of the environment dict |
| 353 | 380 | ||
| 354 | :param env: The environment dict | 381 | :param env: The environment dict |
| 355 | :type env: dict | 382 | :type env: dict |
| 383 | |||
| 384 | :return: Printable representation of the environment dict | ||
| 385 | :rtype: str | ||
| 356 | """ | 386 | """ |
| 387 | env_str = '' | ||
| 357 | for key, value in env.items(): | 388 | for key, value in env.items(): |
| 358 | if key in self._sensitive_env_variables: | 389 | if key in self._sensitive_env_variables: |
| 359 | print(f'{key}: ***') | 390 | env_str += f'{key}: ***{os.linesep}' |
| 360 | continue | 391 | continue |
| 361 | print(f'{key}: {value}') | 392 | env_str += f'{key}: {value}{os.linesep}' |
| 393 | return env_str | ||
| 362 | 394 | ||
| 363 | def get_environ(self) -> dict: | 395 | def get_environ(self) -> dict: |
| 364 | """ | 396 | """ |
| @@ -421,6 +453,50 @@ class EtoolkitInstance: | |||
| 421 | return self.name | 453 | return self.name |
| 422 | return self._parent.get_full_name(delimiter) + delimiter + self.name | 454 | return self._parent.get_full_name(delimiter) + delimiter + self.name |
| 423 | 455 | ||
| 456 | def get_reencrypted_instance_data( | ||
| 457 | self, new_password: str, password: str = None | ||
| 458 | ) -> dict: | ||
| 459 | """ | ||
| 460 | Returns new instance data (dict) containing new encrypted values | ||
| 461 | |||
| 462 | Each encrypted value in this instance is decrypted using `password` | ||
| 463 | and then encrypted again using `new_password` | ||
| 464 | |||
| 465 | If `password` is None, master_password is not set earlier for this | ||
| 466 | instance and 'ETOOLKIT_MASTER_PASSWORD' is not set, | ||
| 467 | the prompt function will be called | ||
| 468 | |||
| 469 | :param new_password: The password to reencrypt with | ||
| 470 | :type new_password: str | ||
| 471 | |||
| 472 | :param password: The password to decrypt current encrypted values with | ||
| 473 | :type password: str or None | ||
| 474 | |||
| 475 | :return: New instance data | ||
| 476 | :rtype: dict | ||
| 477 | """ | ||
| 478 | if password is None: | ||
| 479 | password = self._master_password | ||
| 480 | |||
| 481 | if password is None and self._prompt_func is None: | ||
| 482 | password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') | ||
| 483 | if password is None: | ||
| 484 | raise EtoolkitInstanceError( | ||
| 485 | 'Neither password or prompt function set' | ||
| 486 | ) | ||
| 487 | |||
| 488 | if password is None: | ||
| 489 | password = self._prompt_func( | ||
| 490 | self._master_password_hash, confirm=False | ||
| 491 | ) | ||
| 492 | |||
| 493 | new_data = dict(self._instance_data) | ||
| 494 | for key, value in self._instance_data.items(): | ||
| 495 | if isinstance(value, str) and value.startswith('enc-val$'): | ||
| 496 | new_data[key] = self.reencrypt(password, new_password, value) | ||
| 497 | |||
| 498 | return new_data | ||
| 499 | |||
| 424 | def _decrypt_value(self, evalue: str) -> str: | 500 | def _decrypt_value(self, evalue: str) -> str: |
| 425 | """ | 501 | """ |
| 426 | Decrypts an encrypted value using the master password | 502 | Decrypts an encrypted value using the master password |
| @@ -452,4 +528,4 @@ class EtoolkitInstance: | |||
| 452 | self.master_password = self._prompt_func( | 528 | self.master_password = self._prompt_func( |
| 453 | self._master_password_hash, confirm=False | 529 | self._master_password_hash, confirm=False |
| 454 | ) | 530 | ) |
| 455 | return EtoolkitInstance.decrypt(self._master_password, evalue) | 531 | return self.decrypt(self._master_password, evalue) |
