From 08a3280b062af83ee50fa139d7827d954907886e Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Mon, 13 May 2024 21:52:13 +0200 Subject: [PATCH] Implement re-encryption support --- .ruff.toml | 73 +++++ CHANGELOG.md | 15 + README.md | 133 ++++++-- completion/etoolkit.bash | 7 +- etoolkit_sample.json | 16 +- src/etoolkit/__init__.py | 2 +- src/etoolkit/__main__.py | 379 +++++++++++++++-------- src/etoolkit/etoolkit.py | 110 ++++++- tests/conftest.py | 61 +++- tests/test_cli.py | 57 ++-- tests/test_envtoolkit_instance.py | 6 +- tests/test_envtoolkit_instance_static.py | 136 ++++---- 12 files changed, 729 insertions(+), 266 deletions(-) create mode 100644 .ruff.toml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..add2491 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,73 @@ +cache-dir = "~/.cache/ruff" +indent-width = 4 +line-length = 79 +target-version = "py312" + +[lint] +select = ["ALL"] +ignore = ["ANN", "COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D401", "D403", "D415", "ERA001", "FBT001", "FBT002", "PTH111", "RUF012", "RUF013", "S101", "TRY300", "BLE001", "UP020", "C901", "D200", "D402", "EM101", "EM102", "FBT003", "INP001", "PLR0912", "PLR0913", "PLR0915", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"] +# D105 - Missing docstring in magic method +# D200 - One-line docstring should fit on one line +# D203 - 1 blank line required before class docstring +# D205 - 1 blank line required between summary line and description +# D403 - First word of the first line should be capitalized: `str` -> `Str` +# FBT001 - Boolean-typed positional argument in function definition +# FBT002 - Boolean default positional argument in function definition +# PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` +# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` +# RUF013 - PEP 484 prohibits implicit `Optional` +# S101 - Use of `assert` detected +# TRY300 - Consider moving this statement to an `else` block +# TRY400 - Use `logging.exception` instead of `logging.error` +# UP020 - Use builtin `open` + +# Project specific +# C901 - `X` is too complex +# D200 - One-line docstring should fit on one line +# D402 - First line should not be the function's signature (bug in ruff 0.4.4) +# EM101 - Exception must not use a string literal, assign to variable first +# EM102 - Exception must not use an f-string literal, assign to variable first +# FBT003 - Boolean positional value in function call +# INP001 - File `tests/test_envtoolkit_instance_static.py` is part of an implicit namespace package. Add an `__init__.py`. +# PLR0912 - Too many branches +# PLR0913 - Too many arguments in function definition +# PLR0915 - Too many statements +# PLR2004 - Magic value used in comparison, consider replacing `X` with a constant variable +# PLW2901 - `for` loop variable `value` overwritten by assignment target +# S603 - `subprocess` call: check for execution of untrusted input +# T201 - `print` found +# TRY003 - Avoid specifying long messages outside the exception class + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +[format] +# Like Black, use double quotes for strings. +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = true + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" + +[lint.flake8-quotes] +inline-quotes = "single" diff --git a/CHANGELOG.md b/CHANGELOG.md index d48f9a9..aca6068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.0.0](https://github.com/blackm0re/etoolkit/tree/2.0.0) (2024-05-13) + +[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.2.0...2.0.0) + +**Changes:** + +- etoolkit encryption format v2, adding rnd. padding for values < 32 bytes + +- re-encryption support + +- replaced *os.system* with *subprocess* + +- new etoolkit.EtoolkitInstance API (not compatible with v1) + + ## [1.2.0](https://github.com/blackm0re/etoolkit/tree/1.2.0) (2022-04-04) [Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.1.0...1.2.0) diff --git a/README.md b/README.md index 6c58bdd..45c230a 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,97 @@ for processes that were not spawned by that same *etoolkit* session. # add sgs' custom repository using app-eselect/eselect-repository eselect repository add sgs - # ... or using layman (obsolete) - layman -a sgs - emerge dev-python/etoolkit ``` +## Encryption & decryption scheme + +The etoolkit encryption format is currently at version 2. +Encrypted values start with *enc-val$2$*. + +This new version introduces padding for values that are shorter than 32 bytes. +The idea behind padding is to generate (32 - value length) random bytes and +append them to the original value. +That prevents a potential attacker from knowing the length of the encrypted +short value (f.i. password, PIN number, username... etc). + +Values encrypted in the old format (*enc-val$1$*) can still be decrypted +seamlessly. + +Authenticated encryption with associated data (AEAD) is implemented using +AES-GCM. + + +### Encryption + +Input: + +- plain-text value to be encrypted (P) + +- plain-text master-password used for key derivation (M) + + +Output: + +- an encrypted value digest (base64) (B) + + +Operation: + +- generate 32 bytes of random data to be used as a salt (S) + +- derive a 32 bytes key (K): K = scrypt(M, S, n=2**14, r=8, p=1) + +- use the first 12 bytes of S as nonce (NONCE) + +- calculate the padding length (L) as 32 - length of P, if P < 32, 0 otherwise + +- set the padding length bytes (N) (2bytes) to "%02d", if L > 0, "-1" otherwise + +- generate L bytes of random data to be used for padding (D) + +- encrypt and auth. P, auth.only S (E): E = AES_GCM_ENC(K, NONCE, N + P + D, S) + +- encrypted value digest (B) = enc-val$2$:BASE64_ENCODE(S)$BASE64_ENCODE(E) + +example: +enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hFSujpe35X/XpzBegJJpo86AiCsNsUS6B6JM= + + +### Decryption + +Input: + +- encrypted value digest (base64) (B) + +- plain-text master-password used for key derivation (M) + + +Output: + +- plain-text password (P) + + +Operation: + +- remove the prefix (enc-val$2$) from B and split the remaining value by '$' + +- base64-decode the salt (S): S = BASE64_DECODE(B1) + +- base64-decode the rest of the data (E): E = BASE64_DECODE(B2) + +- derive a 32 bytes key (K): K = scrypt(M, S, n=2**14, r=8, p=1) + +- use the first 12 bytes of S as nonce (NONCE) + +- decrypt the encrypted data (D): D = AES_GCM_DECRYPT(K, NONCE, E, S) + +- fetch the first 2 bytes (padding length bytes) (N): N = D[0 : 2] + +- calculate the padding length (L): L = INT(N) if N != "-1", 0 otherwise + +- fetch the plain-text (P): P = D[2 : -L] if L != 0, D[2 :] otherwise + ## Setup and examples @@ -201,6 +286,17 @@ One can also spawn a different process than an interactive shell by using the etoolkit --spawn /bin/othershell ``` +It is possible to re-encrypt all encrypted values in a specific instance or in +all defined instances either by using the same or a new master password. + + ```bash + etoolkit --reencrypt all + ``` + +will prompt for the current master password, then for a new master password +(with confirmation) and finally the new config file (if "all") or instance +contents will be displayed. + Contact the author for questions and suggestions! :) @@ -223,11 +319,11 @@ or the *instances* structure being loaded from a diferent configuration file # using some static methods in order to create encrypted values - etoolkit.EtoolkitInstance.encrypt('the very secret passwd', 'secret1') - # Out: 'enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9o+ABvsxogIXpJim16Gz5SVtV8=' + etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'secret1') + # Out: 'enc-val$2$NDdp6WMbX7gdEyzGM5nI4jhyer4XL+BoQwAHtL2CXHw=$+Pztn1pfaXKjPpem5PIQrCNxR9pyE6zqgSoGg9qXvmhH6VsNQvUTmiaOvUFl35EbiYE=' - etoolkit.EtoolkitInstance.encrypt('the very secret passwd', 'secret2') - # Out: 'enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=' + etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'secret2') + # Out: 'enc-val$2$H953GxW+qrYXIp+I97lJBmG1gv89wxcfmTu7PEpZzjE=$Tb3F8/izDbHAMklpIjYk73JAiav+w8ZhrMsO93FlQjGh4MTChjp2Yen5BxSBOWLvCD4=' # The encrypted values will be used in our configuration structure @@ -237,34 +333,33 @@ or the *instances* structure being loaded from a diferent configuration file }, "instances": { "_default": { - "ETOOLKIT_PROMPT": "(%i)", - "PYTHONPATH": "/home/user/%i/python", + "ETOOLKIT_PROMPT": "(%i)", + "ETOOLKIT_SENSITIVE": ["DB_CONNECTION", "ETOOLKIT_TEST_PASSWORD"] }, "dev": { "ETOOLKIT_PARENT": "_default", - "PYTHONPATH": "%p:/home/user/%i/.pythonpath", + "PYTHONPATH": ":/home/user/.pythonpath", + "DB_CONNECTION": "enc-val$2$RAgDei59tUvDAkrBmxROqRaV/NxNFEI2eJIOP7sG/b8=$yse7zawHCzQCU31sZj4oJYLGonz1M7oqHqCilXLHkywa9nMPALypmVzi3QekekYuLeb5XVTmmp84NHoPn1M052otoRHSp+TMPsqBPRabfriIKEK4XQ==" }, "secret": { "ETOOLKIT_PARENT": "_default", - "ETOOLKIT_SENSITIVE": ["PASSWORD"], "GNUPGHOME": "%h/private/.gnupg", - "PASSWORD": "enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=" + "ETOOLKIT_TEST_PASSWORD": "enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=" } } } - - dev_instance = etoolkit.EtoolkitInstance('dev', instances) + secret_instance = etoolkit.EtoolkitInstance('secret', instances) # fetch the variables before the processing stage (calling get_environ()) # since raw_env_variables is a dict, it can be modified (f.i. .update()) - dev_instance.raw_env_variables + secret_instance.raw_env_variables - dev_instance.master_password = 'the very secret passwd' # or perhaps using getpass - env_vars = dev_instance.get_env() - print(env_vars['PASSWORD']) # outputs: 'secret2' + secret_instance.master_password = 'The very secret passwd' # or perhaps using getpass + env_vars = secret_instance.get_environ() + print(env_vars['ETOOLKIT_TEST_PASSWORD']) # outputs: 'secret1' - inst.dump_env(env_vars) # prints all values, with the exception of 'PASSWORD' + secret_instance.env_to_str(env_vars) # prints all values, with the exception of 'ETOOLKIT_TEST_PASSWORD' # set the env. variables. os.environ.update(env_vars) diff --git a/completion/etoolkit.bash b/completion/etoolkit.bash index 2e80811..8faefc2 100644 --- a/completion/etoolkit.bash +++ b/completion/etoolkit.bash @@ -30,7 +30,7 @@ _etoolkit() { all_params="-d --decrypt-value -e --encrypt-value -l --list -h --help -P --master-password-prompt -p --generate-master-password-hash -c --config-file -E --echo -m --multiple-values -q --no-output - -s --spawn -v --version" + -r --reencrypt -s --spawn -v --version" # if [ ${prev:0:1} == "-" ] if [ ${COMP_CWORD} -eq 1 ]; then @@ -78,6 +78,11 @@ _etoolkit() { COMPREPLY=($(compgen -W "-d --decrypt-value -E -e --echo --encrypt-value -m --multiple-values" -- "$cur")) return ;; + "-r" | "--reencrypt") + COMPREPLY=($(compgen -W "all" -- "$cur")) + _instances "$cur" + return + ;; "-s" | "--spawn") COMPREPLY=($(compgen -c -- "$cur")) return diff --git a/etoolkit_sample.json b/etoolkit_sample.json index 66c9d17..af3ed75 100644 --- a/etoolkit_sample.json +++ b/etoolkit_sample.json @@ -1,21 +1,21 @@ { "general": { - "MASTER_PASSWORD_HASH": "pbkdf2_sha256$100000$kFOQkAPtStZ/Ny/O4501ygHGQnqh5Y+ySxF9qVHriv8=$3BujuWzn3CfDnw4yiD9m3F+GjeW1MHHW40R/ThHNcn0=" + "MASTER_PASSWORD_HASH": "pbkdf2_sha256$500000$UY3o78KUM1Btzxk3k3JCsijnwtJ2lx+hH9NewpVKxo8=$tHwDm8OVKanC4DoYTigTCb0R3lQIa/CbBYj0B3TZtHg=" }, "instances": { - "default": { - "ETOOLKIT_PROMPT": "(%i)" + "_default": { + "ETOOLKIT_PROMPT": "(%i)", + "ETOOLKIT_SENSITIVE": ["DB_CONNECTION", "ETOOLKIT_TEST_PASSWORD"] }, "dev": { - "ETOOLKIT_PARENT": "default", + "ETOOLKIT_PARENT": "_default", "PYTHONPATH": ":/home/user/.pythonpath", - "DB_CONNECTION": "enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9o+ABvsxogIXpJim16Gz5SVtV8=" + "DB_CONNECTION": "enc-val$2$RAgDei59tUvDAkrBmxROqRaV/NxNFEI2eJIOP7sG/b8=$yse7zawHCzQCU31sZj4oJYLGonz1M7oqHqCilXLHkywa9nMPALypmVzi3QekekYuLeb5XVTmmp84NHoPn1M052otoRHSp+TMPsqBPRabfriIKEK4XQ==" }, "secret": { - "ETOOLKIT_PARENT": "default", - "ETOOLKIT_SENSITIVE": ["PASSWORD"], + "ETOOLKIT_PARENT": "_default", "GNUPGHOME": "%h/private/.gnupg", - "PASSWORD": "enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=" + "ETOOLKIT_TEST_PASSWORD": "enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=" } } } 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 @@ from .etoolkit import EtoolkitInstance, EtoolkitInstanceError __author__ = 'Simeon Simeonov' -__version__ = '1.3.0' +__version__ = '2.0.0' __license__ = 'GPL3' 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 @@ logger = logging.getLogger(__name__) -def decrypt_value(args: argparse.Namespace, config: dict): +class EtoolkitCLIHandler: """ - Interactive function for decrypting value(s) + Helper class used for handleing the growing amount of arguments - Prompts for master key password and then prompts for a value to decrypt + This class consists mostly of interactive methods and is not intended as + a part of the etoolkit API + """ - The decrypted value is printed to stdout + def __init__(self, args: argparse.Namespace, config_dict: dict): + """ + :param args: The parsed argparse arguments sent by the caller + :type args: argparse.Namespace + + :param config_dict: The config file structure + :type config_dict: dict + """ + self._args = args + self._config_dict = config_dict + + self._password_hash = None + if 'general' in config_dict: + self._password_hash = config_dict['general'].get( + 'MASTER_PASSWORD_HASH' + ) - :param args: The arguments sent by the caller - :type args: arparse.Namespace + self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD') - :param config: The config dict sent by the caller - :type config: dict - """ - password_hash = None - pipe_input = None - if not os.isatty(sys.stdin.fileno()): - pipe_input = sys.stdin.read().strip() - if 'general' in config: - password_hash = config['general'].get('MASTER_PASSWORD_HASH') - - if ( - args.master_password_prompt - or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None - ): - password = etoolkit.EtoolkitInstance.confirm_password_prompt( - password_hash, False - ) - else: - password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') - - if pipe_input: - # the input came from stdin. No need to prompt - print( - 'Decrypted value: ' - f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' - ) - return - while True: - try: - value = input('Value: ') + def decrypt_value(self): + """ + Interactive method for decrypting value(s) + + Prompts for master key password and then prompts for a value to decrypt + + The decrypted value is printed to stdout + """ + pipe_input = None + if not os.isatty(sys.stdin.fileno()): + pipe_input = sys.stdin.read().strip() + + if ( + self._args.master_password_prompt + or self._password_from_env is None + ): + password = self._password_prompt() + else: + password = self._password_from_env + + if pipe_input: + # the input came from stdin. No need to prompt print( 'Decrypted value: ' - f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' + f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' ) - if not args.multiple_values: + return + while True: + try: + value = input('Value: ') + print( + 'Decrypted value: ' + f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' + ) + if not self._args.multiple_values: + break + except KeyboardInterrupt: + print(os.linesep) break - except KeyboardInterrupt: - print(os.linesep) - break - return + return + def encrypt_value(self): + """ + Interactive method for encrypting value(s) -def encrypt_value(args: argparse.Namespace, config: dict): - """ - Interactive function for encrypting value(s) + Prompts for master key password and then prompts for a value to encrypt + + The encrypted value is printed to stdout + """ + pipe_input = None + if not os.isatty(sys.stdin.fileno()): + pipe_input = sys.stdin.read().strip() + + if ( + self._args.master_password_prompt + or self._password_from_env is None + ): + password = self._password_prompt_confirm() + else: + password = self._password_from_env - Prompts for master key password and then prompts for a value to encrypt + if pipe_input: + # the input came from stdin. No need to prompt + print( + 'Encrypted value: ' + f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' + ) + return + + while True: + try: + if self._args.echo: + value = input('Value: ') + else: + value = getpass.getpass('Value: ') + print( + 'Encrypted value: ' + f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' + ) + if not self._args.multiple_values: + break + except KeyboardInterrupt: + print(os.linesep) + break + return - The encrypted value is printed to stdout + def generate_master_password_hash(self): + """ + Interactive method for generating password hash - :param args: The arguments sent by the caller - :type args: arparse.Namespace + Prompts for master key password and then for confirmation - :param config: The config dict sent by the caller - :type config: dict - """ - password_hash = None - pipe_input = None - if not os.isatty(sys.stdin.fileno()): - pipe_input = sys.stdin.read().strip() - if 'general' in config: - password_hash = config['general'].get('MASTER_PASSWORD_HASH') - - if ( - args.master_password_prompt - or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None - ): - password = etoolkit.EtoolkitInstance.confirm_password_prompt( - password_hash + The generated hash is printed to stdout + """ + phash = etoolkit.EtoolkitInstance.get_new_password_hash( + etoolkit.EtoolkitInstance.confirm_password_prompt() ) - else: - password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') - - if pipe_input: - # the input came from stdin. No need to prompt - print( - 'Encrypted value: ' - f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' + print(f'Master password hash: {phash}') + + def list(self): + """Lists all instances defined in the config file""" + + for instance_name in sorted( + filter( + lambda s: not s.startswith('_'), + self._config_dict.get('instances', {}).keys(), + ) + ): + print(instance_name) + + def load_instance(self): + """Loads a single specified instance from the config file""" + + inst = etoolkit.EtoolkitInstance( + self._args.instance, self._config_dict ) - return - while True: - try: - if args.echo: - value = input('Value: ') - else: - value = getpass.getpass('Value: ') + + if ( + self._args.master_password_prompt + or self._password_from_env is None + ): + inst.prompt_func = ( + etoolkit.EtoolkitInstance.confirm_password_prompt + ) + + env = inst.get_environ() + + if self._args.dump_output: + print(inst.env_to_str(env)) + + os.environ.update(env) + + if self._args.spawn: + subprocess.run(self._args.spawn.split(), check=False) + else: + subprocess.run( + os.environ.get('SHELL', 'bash').split(), check=False + ) + + def reencrypt(self): + """ + Interactive method that prints new configuration data (JSON) to stdout + + Prompts for master key password and then for a new password, + which may be the same as the current password + + All existing encrypted values are decrypted using the current password + and then encrypted with the new password + """ + print('(Current password) ', end='', flush=True) + if ( + self._args.master_password_prompt + or self._password_from_env is None + ): + password = self._password_prompt() + else: + password = self._password_from_env + + print('(New password) ', end='', flush=True) + new_password = etoolkit.EtoolkitInstance.confirm_password_prompt() + + if self._args.reencrypt != 'all': + # re-encrypt a single instance + inst = etoolkit.EtoolkitInstance( + self._args.reencrypt, self._config_dict + ) print( - 'Encrypted value: ' - f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' + json.dumps( + inst.get_reencrypted_instance_data(new_password, password), + indent=4, + ) + ) + return + + # re-encrypt all + new_config_dict = dict(self._config_dict) + if ( + 'general' in new_config_dict + and 'MASTER_PASSWORD_HASH' in new_config_dict['general'] + ): + new_config_dict['general']['MASTER_PASSWORD_HASH'] = ( + etoolkit.EtoolkitInstance.get_new_password_hash(new_password) ) - if not args.multiple_values: - break - except KeyboardInterrupt: - print(os.linesep) - break - return + + for instance_name in self._config_dict['instances']: + inst = etoolkit.EtoolkitInstance(instance_name, self._config_dict) + new_config_dict['instances'][instance_name] = ( + inst.get_reencrypted_instance_data(new_password, password) + ) + print(json.dumps(new_config_dict, indent=4)) + + def _password_prompt(self) -> str: + """ + Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=False) + """ + return etoolkit.EtoolkitInstance.confirm_password_prompt( + self._password_hash, False + ) + + def _password_prompt_confirm(self) -> str: + """ + Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=True) + """ + return etoolkit.EtoolkitInstance.confirm_password_prompt( + self._password_hash + ) def main(inargs=None): """main entry point""" + parser = argparse.ArgumentParser( prog=__package__, epilog=( @@ -207,6 +334,20 @@ def main(inargs=None): required=False, help='Prompt for master password, display the generated hash and exit', ) + group.add_argument( + '-r', + '--reencrypt', + metavar='', + type=str, + default='', + dest='reencrypt', + required=False, + help=( + 'Prompt for current master password, new master password and ' + 're-encrypt either all encrypted values or only those for a ' + 'given instance' + ), + ) parser.add_argument( '-c', '--config-file', @@ -274,7 +415,7 @@ def main(inargs=None): try: with io.open(args.config_file, encoding='utf-8') as fp: config_dict = json.load(fp) - except FileNotFoundError as e: + except FileNotFoundError as err: # do not raise exception if config-file is missing for: # - decrypting value # - encrypting value @@ -288,66 +429,38 @@ def main(inargs=None): config_dict = {} else: logger.error('Configuration file %s is missing', args.config_file) - raise SystemExit(errno.EIO) from e - except Exception as e: + raise SystemExit(errno.EIO) from err + except Exception as exp: logger.exception('Unable to parse %r', args.config_file) - raise SystemExit(errno.EIO) from e + raise SystemExit(errno.EIO) from exp try: + etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict) if args.decrypt_value: - decrypt_value(args, config_dict) + etoolkit_cli_handler.decrypt_value() sys.exit(0) if args.encrypt_value: - encrypt_value(args, config_dict) + etoolkit_cli_handler.encrypt_value() sys.exit(0) if args.password_hash: - master_password = ( - etoolkit.EtoolkitInstance.confirm_password_prompt() - ) - phash = etoolkit.EtoolkitInstance.get_new_password_hash( - master_password - ) - print(f'Master password hash: {phash}') + etoolkit_cli_handler.generate_master_password_hash() sys.exit(0) if args.list: - for instance_name in sorted( - filter( - lambda s: not s.startswith('_'), - config_dict.get('instances', {}).keys(), - ) - ): - print(instance_name) + etoolkit_cli_handler.list() + sys.exit(0) + if args.reencrypt: + etoolkit_cli_handler.reencrypt() sys.exit(0) - inst = etoolkit.EtoolkitInstance(args.instance, config_dict) - if ( - args.master_password_prompt - or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None - ): - inst.prompt_func = ( - etoolkit.EtoolkitInstance.confirm_password_prompt - ) - env = inst.get_environ() - - if args.dump_output: - inst.dump_env(env) - - os.environ.update(env) - - if args.spawn: - subprocess.run(args.spawn.split(), check=False) - else: - subprocess.run( - os.environ.get('SHELL', 'bash').split(), check=False - ) + etoolkit_cli_handler.load_instance() except KeyboardInterrupt: logger.debug('KeyboardInterrupt') print(os.linesep) sys.exit(0) - except etoolkit.EtoolkitInstanceError as e: - logger.error('EtoolkitInstanceError: %s', e) + except etoolkit.EtoolkitInstanceError as err: + logger.error('EtoolkitInstanceError: %s', err) sys.exit(1) - except subprocess.CalledProcessError as e: - logger.error('Unable to spawn shell process: %s', e) + except subprocess.CalledProcessError as err: + logger.error('Unable to spawn shell process: %s', err) sys.exit(1) except Exception: 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 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM - MIN_ENCRYPTED_VALUE_LENGTH = 32 @@ -50,24 +49,26 @@ def __init__(self, name: str, data: dict): self._master_password_hash = None self._prompt_func = None # function to use when prompting for input try: - inst_data = data['instances'][name] - except KeyError as e: - raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e - if inst_data.get('ETOOLKIT_PARENT'): - self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data) + self._instance_data = data['instances'][name] + except KeyError as err: + raise EtoolkitInstanceError(f'Unknown instance "{name}"') from err + if self._instance_data.get('ETOOLKIT_PARENT'): + self._parent = EtoolkitInstance( + self._instance_data['ETOOLKIT_PARENT'], data + ) self._raw_env_variables.update(self._parent.raw_env_variables) self._sensitive_env_variables.extend( self._parent.sensitive_env_variables ) - if inst_data.get('ETOOLKIT_SENSITIVE'): - if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list): + if self._instance_data.get('ETOOLKIT_SENSITIVE'): + if not isinstance(self._instance_data['ETOOLKIT_SENSITIVE'], list): raise EtoolkitInstanceError( '"ETOOLKIT_SENSITIVE" must be a list' ) self._sensitive_env_variables.extend( - inst_data['ETOOLKIT_SENSITIVE'] + self._instance_data['ETOOLKIT_SENSITIVE'] ) - self._raw_env_variables.update(inst_data) + self._raw_env_variables.update(self._instance_data) # remove non env. variable data self._raw_env_variables.pop('ETOOLKIT_PARENT', None) self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None) @@ -200,7 +201,7 @@ def decrypt(password: str, edata: str) -> str: # padding_length_bytes(2 bytes) data padding (between 0 and 32) # extract padding_length_bytes - if data[:2] == b'--': + if data[:2] == b'-1' or data[:2] == b'--': data = data[2:] else: data = data[2 : -int(data[:2].decode())] @@ -259,7 +260,7 @@ def encrypt(password: str, data: str) -> str: ) ) nonce = salt[:12] - padding_length_bytes = b'--' # no padding used 2 bytes "sign" + padding_length_bytes = b'-1' # no padding used 2 bytes "sign" edata = aesgcm.encrypt( nonce, padding_length_bytes + data_bytes, salt ) @@ -347,18 +348,49 @@ def password_matches(password: str, password_hash: str) -> bool: except Exception: return False - def dump_env(self, env: dict): + @staticmethod + def reencrypt(password: str, new_password: str, edata: str) -> str: + """ + Re-encrypts `edata` using `password` and `new_password`. + + Version 2 of the etoolkit encryption format + + `edata` is in the following format: + enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` + + :param password: The password to decrypt `edata` with + :type password: str + + :param new_password: The password to re-encrypt the plain-text with + :type new_password: str + + :param edata: The data to be re-encrypted + :type edata: str + + :return: The new encrypted string string + :rtype: str + """ + return EtoolkitInstance.encrypt( + new_password, EtoolkitInstance.decrypt(password, edata) + ) + + def env_to_str(self, env: dict) -> str: """ - Prints an environment dict to stdout. + Returns a printable str. representation of the environment dict :param env: The environment dict :type env: dict + + :return: Printable representation of the environment dict + :rtype: str """ + env_str = '' for key, value in env.items(): if key in self._sensitive_env_variables: - print(f'{key}: ***') + env_str += f'{key}: ***{os.linesep}' continue - print(f'{key}: {value}') + env_str += f'{key}: {value}{os.linesep}' + return env_str def get_environ(self) -> dict: """ @@ -421,6 +453,50 @@ def get_full_name(self, delimiter: str = '') -> str: return self.name return self._parent.get_full_name(delimiter) + delimiter + self.name + def get_reencrypted_instance_data( + self, new_password: str, password: str = None + ) -> dict: + """ + Returns new instance data (dict) containing new encrypted values + + Each encrypted value in this instance is decrypted using `password` + and then encrypted again using `new_password` + + If `password` is None, master_password is not set earlier for this + instance and 'ETOOLKIT_MASTER_PASSWORD' is not set, + the prompt function will be called + + :param new_password: The password to reencrypt with + :type new_password: str + + :param password: The password to decrypt current encrypted values with + :type password: str or None + + :return: New instance data + :rtype: dict + """ + if password is None: + password = self._master_password + + if password is None and self._prompt_func is None: + password = os.environ.get('ETOOLKIT_MASTER_PASSWORD') + if password is None: + raise EtoolkitInstanceError( + 'Neither password or prompt function set' + ) + + if password is None: + password = self._prompt_func( + self._master_password_hash, confirm=False + ) + + new_data = dict(self._instance_data) + for key, value in self._instance_data.items(): + if isinstance(value, str) and value.startswith('enc-val$'): + new_data[key] = self.reencrypt(password, new_password, value) + + return new_data + def _decrypt_value(self, evalue: str) -> str: """ Decrypts an encrypted value using the master password @@ -452,4 +528,4 @@ def _decrypt_value(self, evalue: str) -> str: self.master_password = self._prompt_func( self._master_password_hash, confirm=False ) - return EtoolkitInstance.decrypt(self._master_password, evalue) + return self.decrypt(self._master_password, evalue) diff --git a/tests/conftest.py b/tests/conftest.py index cbf7152..37dd184 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,9 +45,9 @@ def config_data(): 'ETOOLKIT_SENSITIVE': ['ETOOLKIT_TEST_PASSWORD'], 'GNUPGHOME': '%h/private/.gnupg', 'ETOOLKIT_TEST_PASSWORD': ( - 'enc-val$2$v6F2M7LeUDbQWNLg6WW5mUcbuYYo7aGynSxzWAENVBI=$' - 'ZcyWzf9Kp0aYI8N+biKMSmu4RGGi199ayq' - 'EYdJl+qdq7b1HSutwYlC7UR2GsSofu4Xo=' + 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' + '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqz' + 'oIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=' ), }, }, @@ -63,12 +63,35 @@ def config_file(tmp_path, config_data): return str(cf) +@pytest.fixture() +def long_encrypted_value(): + """enc. value corresponding to 'Nobody expects the Spanish inquisition'""" + + return ( + 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' + 'UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hF' + 'Sujpe35X/XpzBegJJpo86AiCsNsUS6B6JM=' + ) + + +@pytest.fixture() +def long_value(): + """standard value (> 32 bytes)""" + return 'Nobody expects the Spanish inquisition' + + @pytest.fixture() def master_password(): """Master passord""" return 'The very secret passwd' +@pytest.fixture() +def new_master_password(): + """Master passord""" + return 'New very secret passwd' + + @pytest.fixture() def non_random_bytes_32(): """always use the same bytes instead of os.urandom(32)""" @@ -80,13 +103,13 @@ def non_random_bytes_32(): @pytest.fixture() -def non_random_bytes_61(): - """always use the same bytes instead of os.urandom(61)""" +def non_random_bytes_57(): + """always use the same bytes instead of os.urandom(57)""" return ( b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa' b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e' - b'\xa4s(Q\x04\xcd\xc7T\x01_D\xb1' + b'\xa4s(Q\x04\xcd\xc7T' ) @@ -107,6 +130,32 @@ def password_hash(): ) +@pytest.fixture() +def short_encrypted_value(): + """enc. value corresponding to 'secret1'""" + + return ( + 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' + '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U=' + ) + + +@pytest.fixture() +def short_encrypted_value_v1(): + """enc. value (enc-val 1) corresponding to 'secret1'""" + + return ( + 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx' + 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' + ) + + +@pytest.fixture() +def short_value(): + """standard value (< 32 bytes)""" + return 'secret1' + + @pytest.fixture() def wrong_master_password(): """Wrong master passord""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 1693a2b..953abf4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,14 +44,17 @@ def test_decrypt_v1(binput, capsys, config_file, master_password): @unittest.mock.patch('builtins.input') -def test_decrypt_v2(binput, capsys, config_file, master_password): +def test_decrypt_v2( + binput, + capsys, + config_file, + master_password, + short_encrypted_value, + short_value, +): """Tests v2 decryption via the CLI interface""" - binput.return_value = ( - 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' - 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E' - 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' - ) + binput.return_value = short_encrypted_value with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} ): @@ -59,18 +62,27 @@ def test_decrypt_v2(binput, capsys, config_file, master_password): main(['-c', f'{config_file}', '-d']) assert exit_info.type == SystemExit assert exit_info.value.code == 0 - assert capsys.readouterr().out.strip() == 'Decrypted value: bar' + assert capsys.readouterr().out.strip() == ( + f'Decrypted value: {short_value}' + ) @unittest.mock.patch('os.urandom') @unittest.mock.patch('builtins.input') def test_encrypt_with_echo( - binput, urandom, capsys, non_random_bytes_61, config_file, master_password + binput, + urandom, + capsys, + non_random_bytes_57, + config_file, + master_password, + short_encrypted_value, + short_value, ): """Tests encryption via the CLI interface""" - binput.return_value = 'bar' - urandom.return_value = non_random_bytes_61 + binput.return_value = short_value + urandom.return_value = non_random_bytes_57 with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} ): @@ -79,21 +91,26 @@ def test_encrypt_with_echo( assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( - 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' - 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E' - 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' + f'Encrypted value: {short_encrypted_value}' ) @unittest.mock.patch('os.urandom') @unittest.mock.patch('getpass.getpass') def test_encrypt_without_echo( - getpass, urandom, capsys, non_random_bytes_61, config_file, master_password + getpass, + urandom, + capsys, + non_random_bytes_57, + config_file, + master_password, + short_encrypted_value, + short_value, ): """Tests encryption via the CLI interface""" - getpass.return_value = 'bar' - urandom.return_value = non_random_bytes_61 + getpass.return_value = short_value + urandom.return_value = non_random_bytes_57 with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} ): @@ -102,13 +119,11 @@ def test_encrypt_without_echo( assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( - 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' - 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E' - 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' + f'Encrypted value: {short_encrypted_value}' ) -def test_fetch_encrypted_value(config_file, master_password): +def test_fetch_encrypted_value(config_file, master_password, short_value): """Tests decryption of encrypted value""" with unittest.mock.patch.dict( @@ -116,7 +131,7 @@ def test_fetch_encrypted_value(config_file, master_password): ): assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) - assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == 'bar' + assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == short_value def test_list(capsys, config_file, nonexistent_config_file): diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py index 0147c6f..9873d57 100644 --- a/tests/test_envtoolkit_instance.py +++ b/tests/test_envtoolkit_instance.py @@ -41,7 +41,9 @@ def test_instantiation(config_data): assert instance.master_password is None -def test_get_environ(config_data, master_password, wrong_master_password): +def test_get_environ( + config_data, master_password, short_value, wrong_master_password +): """Tests the EtoolkitInstance.get_environ method""" instance = etoolkit.EtoolkitInstance('secret', config_data) @@ -61,7 +63,7 @@ def test_get_environ(config_data, master_password, wrong_master_password): instance.master_password = master_password env = instance.get_environ() assert isinstance(env, dict) - assert env['ETOOLKIT_TEST_PASSWORD'] == 'bar' + assert env['ETOOLKIT_TEST_PASSWORD'] == short_value def test_get_full_name(config_data): diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py index b3b24f0..ee5cf9e 100644 --- a/tests/test_envtoolkit_instance_static.py +++ b/tests/test_envtoolkit_instance_static.py @@ -37,18 +37,14 @@ def test_confirm_password_prompt(getpass, password_hash, master_password): ) -def test_decrypt_v1(master_password): +def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value): """Tests the static EtoolkitInstance.decrypt method""" assert ( etoolkit.EtoolkitInstance.decrypt( - master_password, - ( - 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx' - 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' - ), + master_password, short_encrypted_value_v1 ) - == 'secret1' + == short_value ) # now test with modified edata @@ -62,117 +58,106 @@ def test_decrypt_v1(master_password): assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' -def test_decrypt_v2_no_padding(master_password): +def test_decrypt_v2_no_padding( + master_password, long_encrypted_value, long_value +): """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" assert ( etoolkit.EtoolkitInstance.decrypt( - master_password, - ( - 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$' - 'SQc0ox6Emf2m5rrumsiptpIZEujdpXXSR/' - '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8=' - ), + master_password, long_encrypted_value ) - == 'Nobody expects the Spanish inquisition' + == long_value ) - # now test with modified edata - edata = ( - 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$' - 'SQc0ox6Emf2m4rrumsiptpIZEujdpXXSR/' - '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8=' - ) + # now test with modified encrypted data + edata = long_encrypted_value[:60] + '5' + long_encrypted_value[61:] + with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: etoolkit.EtoolkitInstance.decrypt(master_password, edata) assert exc_info.type is etoolkit.EtoolkitInstanceError assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' -def test_decrypt_v2_with_padding(master_password): +def test_decrypt_v2_with_padding( + master_password, short_encrypted_value, short_value +): """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" assert ( etoolkit.EtoolkitInstance.decrypt( - master_password, - ( - 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$' - 'rhRona4wP9nhnXjcHqwkjFDsiVVVjYanAs' - 'N4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w=' - ), + master_password, short_encrypted_value ) - == 'secret1' + == short_value ) # now test with modified edata - edata = ( - 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$' - 'rhRona4wP8nhnXjcHqwkjFDsiVVVjYanAsN4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w=' - ) + edata = short_encrypted_value[:60] + '5' + short_encrypted_value[61:] with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: etoolkit.EtoolkitInstance.decrypt(master_password, edata) assert exc_info.type is etoolkit.EtoolkitInstanceError assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' -def test_encrypt_no_padding(master_password): +def test_encrypt_no_padding(master_password, long_value): """Tests the static EtoolkitInstance.encrypt method with a long string""" - edata = etoolkit.EtoolkitInstance.encrypt( - master_password, 'Nobody expects the Spanish inquisition' - ) + edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) assert edata.startswith('enc-val$2$') assert len(edata) == 131 # the edata should always be different because of random salting assert edata != etoolkit.EtoolkitInstance.encrypt( - master_password, 'Nobody expects the Spanish inquisition' + master_password, long_value ) -def test_encrypt_with_padding(master_password): +def test_encrypt_with_padding(master_password, short_value): """Tests the static EtoolkitInstance.encrypt method with a short string""" - edata = etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') + edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) assert edata.startswith('enc-val$2$') assert len(edata) == 123 # the edata should always be different because of random salting - assert edata != etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') + assert edata != etoolkit.EtoolkitInstance.encrypt( + master_password, short_value + ) @unittest.mock.patch('os.urandom') def test_encrypt_staticly_no_padding( - urandom, master_password, non_random_bytes_32 + urandom, + master_password, + non_random_bytes_32, + long_encrypted_value, + long_value, ): """Tests the EtoolkitInstance.encrypt method always with the same salt""" urandom.return_value = non_random_bytes_32 - edata = etoolkit.EtoolkitInstance.encrypt( - master_password, 'Nobody expects the Spanish inquisition' - ) - assert edata == ( - 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' - 'UX/5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmC' - 'o8hFSujpe35X/XpzAiYv4BV1LNwnSYECsotsgs=' - ) + edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value) + assert edata == long_encrypted_value assert len(edata) == 131 assert edata == etoolkit.EtoolkitInstance.encrypt( - master_password, 'Nobody expects the Spanish inquisition' + master_password, long_value ) @unittest.mock.patch('os.urandom') def test_encrypt_staticly_with_padding( - urandom, master_password, non_random_bytes_61 + urandom, + master_password, + non_random_bytes_57, + short_encrypted_value, + short_value, ): """Tests the EtoolkitInstance.encrypt method always with the same salt""" - urandom.return_value = non_random_bytes_61 - edata = etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') - assert edata == ( - 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' - '+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/ESvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' + urandom.return_value = non_random_bytes_57 + edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value) + assert edata == short_encrypted_value + assert edata == etoolkit.EtoolkitInstance.encrypt( + master_password, short_value ) - assert edata == etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') def test_get_new_password_hash(master_password): @@ -208,3 +193,38 @@ def test_password_matches( assert not etoolkit.EtoolkitInstance.password_matches( wrong_master_password, password_hash ) + + +@unittest.mock.patch('os.urandom') +def test_reencrypt_staticly_with_padding( + urandom, + master_password, + new_master_password, + non_random_bytes_57, + short_encrypted_value, + short_encrypted_value_v1, + short_value, +): + """Tests the EtoolkitInstance.reencrypt method always with the same salt""" + + urandom.return_value = non_random_bytes_57 + # reencrypt (migrate) v1 to current using the same password + edata = etoolkit.EtoolkitInstance.reencrypt( + master_password, master_password, short_encrypted_value_v1 + ) + assert edata == short_encrypted_value + + # same version, same salt, same edata + assert edata == etoolkit.EtoolkitInstance.reencrypt( + master_password, master_password, edata + ) + + # use different password + edata = etoolkit.EtoolkitInstance.reencrypt( + master_password, new_master_password, edata + ) + assert edata != short_encrypted_value + assert ( + etoolkit.EtoolkitInstance.decrypt(new_master_password, edata) + == short_value + )