From c31fa58c85e866b3a5ab04882c7aff655f0b5477 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Tue, 30 Apr 2024 11:39:20 +0200 Subject: Implement etoolkit encryption protocol v2 --- README.md | 4 +- pyproject.toml | 3 + setup.cfg | 5 +- src/etoolkit/__init__.py | 5 +- src/etoolkit/__main__.py | 23 ++++--- src/etoolkit/etoolkit.py | 80 +++++++++++++++------- tests/conftest.py | 35 ++++++++-- tests/test_cli.py | 60 ++++++++++------ tests/test_envtoolkit_instance.py | 8 ++- tests/test_envtoolkit_instance_static.py | 114 ++++++++++++++++++++++++++----- 10 files changed, 248 insertions(+), 89 deletions(-) mode change 100755 => 100644 src/etoolkit/etoolkit.py diff --git a/README.md b/README.md index ae52d21..6c58bdd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ goals were set: ## Requirements -Apart from Python >= 3.7, the only requirement is +Apart from Python >= 3.8, the only requirement is [cryptography](https://pypi.org/project/cryptography/) @@ -317,7 +317,7 @@ Simeon Simeonov - sgs @ LiberaChat ## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) -Copyright (C) 2021-2022 Simeon Simeonov +Copyright (C) 2021-2024 Simeon Simeonov All rights reserved. [Licensed](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) under the diff --git a/pyproject.toml b/pyproject.toml index 1173069..86a1527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,3 +13,6 @@ addopts = "-s" testpaths = [ "tests" ] +pythonpath = [ + "src" +] diff --git a/setup.cfg b/setup.cfg index c218df8..4985370 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,11 +15,12 @@ classifiers = Intended Audience :: System Administrators License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Operating System :: POSIX Topic :: Security :: Cryptography @@ -31,7 +32,7 @@ project_urls = package_dir = = src packages = find: -python_requires = >=3.7 +python_requires = >=3.8 install_requires = cryptography>=3.2 diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index b49bbcf..0ef5957 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,10 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """A simple toolkit for setting environment variables in a flexible way""" + from .etoolkit import EtoolkitInstance, EtoolkitInstanceError __author__ = 'Simeon Simeonov' -__version__ = '1.2.0' +__version__ = '1.3.0' __license__ = 'GPL3' diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index a200453..fba9dc7 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,11 +16,14 @@ """ CLI entry point for the etoolkit package -Examples: +Examples +-------- python -m etoolkit -h python -m etoolkit -p + """ + import argparse import errno import getpass @@ -33,7 +36,7 @@ import sys import etoolkit -DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" +DEFAULT_LOG_FORMAT = '%(levelname)s: %(message)s' DEFAULT_LOG_LEVEL = logging.WARNING logger = logging.getLogger(__name__) @@ -269,7 +272,7 @@ def main(inargs=None): ) args = parser.parse_args(inargs) try: - with io.open(args.config_file, 'r', encoding='utf-8') as fp: + with io.open(args.config_file, encoding='utf-8') as fp: config_dict = json.load(fp) except FileNotFoundError as e: # do not raise exception if config-file is missing for: @@ -278,16 +281,16 @@ def main(inargs=None): # - password hash generation if args.password_hash or args.decrypt_value or args.encrypt_value: logger.warning( - "Configuration file %s is missing, although not required " - "by the provided parameters", + 'Configuration file %s is missing, although not required ' + 'by the provided parameters', args.config_file, ) config_dict = {} else: - logger.error("Configuration file %s is missing", args.config_file) + logger.error('Configuration file %s is missing', args.config_file) raise SystemExit(errno.EIO) from e except Exception as e: - logger.error("Unable to parse %r: %s", args.config_file, e) + logger.exception('Unable to parse %r', args.config_file) raise SystemExit(errno.EIO) from e try: if args.decrypt_value: @@ -338,8 +341,8 @@ def main(inargs=None): except subprocess.CalledProcessError as e: logger.error('Unable to spawn shell process: %s', e) sys.exit(1) - except Exception as e: - logger.error('Unexpected exception: %s', e) + except Exception: + logger.exception('Unexpected exception') sys.exit(1) diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py old mode 100755 new mode 100644 index aab42a0..870a4be --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The main module of the etoolkit package""" + import base64 import getpass import hashlib @@ -23,6 +24,9 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM +MIN_ENCRYPTED_VALUE_LENGTH = 32 + + class EtoolkitInstanceError(Exception): """EtoolkitInstanceError - Generic exceptions related to instances""" @@ -175,24 +179,33 @@ class EtoolkitInstance: :rtype: str """ # check for supported versions - if not edata.startswith('enc-val$1$'): + if not edata.startswith(('enc-val$1$', 'enc-val$2$')): raise EtoolkitInstanceError( f'Unsupported encryption format: {edata}' ) try: - salt, data = [base64.b64decode(t) for t in edata[10:].split('$')] + salt, data = (base64.b64decode(t) for t in edata[10:].split('$')) nonce = salt[:12] aesgcm = AESGCM( hashlib.scrypt( - password.encode('utf-8'), - salt=salt, - n=2**14, - r=8, - p=1, - dklen=32, + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 ) ) - return aesgcm.decrypt(nonce, data, salt).decode() + + # decrypt + data = aesgcm.decrypt(nonce, data, salt) + + if edata.startswith('enc-val$2$'): + # exclusively for the v2 data format: + # padding_length_bytes(2 bytes) data padding (between 0 and 32) + + # extract padding_length_bytes + if data[:2] == b'--': + data = data[2:] + else: + data = data[2 : -int(data[:2].decode())] + + return data.decode() except InvalidTag as e: raise EtoolkitInstanceError( f'Invalid tag when decrypting: {edata}' @@ -207,6 +220,8 @@ class EtoolkitInstance: """ Encrypts `data` using `password`. + Version 2 of the etoolkit encryption format + The output string is in the following format: enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` @@ -219,21 +234,37 @@ class EtoolkitInstance: :return: The output string :rtype: str """ - salt = os.urandom(32) - aesgcm = AESGCM( - hashlib.scrypt( - password.encode('utf-8'), - salt=salt, - n=2**14, - r=8, - p=1, - dklen=32, + data_bytes = data.encode() + if len(data_bytes) < MIN_ENCRYPTED_VALUE_LENGTH: + padding_length = MIN_ENCRYPTED_VALUE_LENGTH - len(data_bytes) + rnd_bytes = os.urandom(32 + padding_length) + salt = rnd_bytes[:32] + aesgcm = AESGCM( + hashlib.scrypt( + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 + ) + ) + nonce = rnd_bytes[:12] + padding_bytes = rnd_bytes[32:] + # padding_length_bytes is always 2 bytes + padding_length_bytes = f'{padding_length:02d}'.encode() + edata = aesgcm.encrypt( + nonce, padding_length_bytes + data_bytes + padding_bytes, salt + ) + else: + salt = os.urandom(32) + aesgcm = AESGCM( + hashlib.scrypt( + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 + ) + ) + nonce = salt[:12] + padding_length_bytes = b'--' # no padding used 2 bytes "sign" + edata = aesgcm.encrypt( + nonce, padding_length_bytes + data_bytes, salt ) - ) - nonce = salt[:12] - edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt) return ( - f'enc-val$1${base64.b64encode(salt).decode()}$' + f'enc-val$2${base64.b64encode(salt).decode()}$' f'{base64.b64encode(edata).decode()}' ) @@ -252,7 +283,7 @@ class EtoolkitInstance: :rtype: str """ hash_algo = 'sha256' - iterations = 100000 + iterations = 500000 salt = os.urandom(32) key = hashlib.pbkdf2_hmac( hash_algo, password.encode('utf-8'), salt, iterations @@ -339,7 +370,6 @@ class EtoolkitInstance: macros = { '%h': os.path.expanduser('~'), '%i': self.name, - # '%f': self.get_full_name(), '%u': getpass.getuser(), } new_env = {} diff --git a/tests/conftest.py b/tests/conftest.py index 5dcc456..e555a49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,14 +14,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Common fixtures""" + import json import pytest -@pytest.fixture +@pytest.fixture() def config_data(): """config_data for testing""" + return { 'general': { 'MASTER_PASSWORD_HASH': ( @@ -51,26 +53,47 @@ def config_data(): } -@pytest.fixture +@pytest.fixture() def config_file(tmp_path, config_data): """temporary config file for testing that includes config_data""" - cf = tmp_path / "etoolkit.json" + + cf = tmp_path / 'etoolkit.json' cf.write_text(json.dumps(config_data)) return str(cf) -@pytest.fixture +@pytest.fixture() def non_random_bytes_32(): """always use the same bytes instead of os.urandom(32)""" + return ( b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N' b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J' ) -@pytest.fixture +@pytest.fixture() +def non_random_bytes_61(): + """always use the same bytes instead of os.urandom(61)""" + + 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' + ) + + +@pytest.fixture() +def nonexistent_config_file(tmp_path): + """temporary config file for testing that includes config_data""" + + return str(tmp_path / 'etoolkitt.json') + + +@pytest.fixture() def password_hash(): """password hash for testing, corresponding to 'The very secret passwd'""" + return ( 'pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$h3' 'PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo=' diff --git a/tests/test_cli.py b/tests/test_cli.py index 684c2e7..e5db244 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Tests for the CLI (etoolkit.__main__""" + import errno import os import unittest.mock @@ -24,11 +25,9 @@ import etoolkit from etoolkit.__main__ import main -@unittest.mock.patch('os.urandom') @unittest.mock.patch('builtins.input') -def test_decrypt(binput, urandom, capsys, non_random_bytes_32, config_file): - """Tests encryption via the CLI interface""" - urandom.return_value = non_random_bytes_32 +def test_decrypt_v1(binput, capsys, config_file): + """Tests v1 decryption via the CLI interface""" binput.return_value = ( 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' @@ -43,11 +42,29 @@ def test_decrypt(binput, urandom, capsys, non_random_bytes_32, config_file): assert capsys.readouterr().out.strip() == 'Decrypted value: bar' +@unittest.mock.patch('builtins.input') +def test_decrypt_v2(binput, capsys, config_file): + """Tests v2 decryption via the CLI interface""" + binput.return_value = ( + 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' + 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np' + '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio=' + ) + with unittest.mock.patch.dict( + os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'} + ): + with pytest.raises(SystemExit) as exit_info: + 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' + + @unittest.mock.patch('os.urandom') @unittest.mock.patch('builtins.input', lambda *args: 'bar') -def test_encrypt_with_echo(urandom, capsys, non_random_bytes_32, config_file): +def test_encrypt_with_echo(urandom, capsys, non_random_bytes_61, config_file): """Tests encryption via the CLI interface""" - urandom.return_value = non_random_bytes_32 + urandom.return_value = non_random_bytes_61 with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'} ): @@ -56,16 +73,17 @@ def test_encrypt_with_echo(urandom, capsys, non_random_bytes_32, config_file): assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( - 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' - 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' + 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' + 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np' + '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio=' ) @unittest.mock.patch('os.urandom') @unittest.mock.patch('getpass.getpass', lambda *args: 'bar') -def test_encrypt_without_echo(gpass, capsys, non_random_bytes_32, config_file): +def test_encrypt_without_echo(gpass, capsys, non_random_bytes_61, config_file): """Tests encryption via the CLI interface""" - gpass.return_value = non_random_bytes_32 + gpass.return_value = non_random_bytes_61 with unittest.mock.patch.dict( os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'} ): @@ -74,19 +92,20 @@ def test_encrypt_without_echo(gpass, capsys, non_random_bytes_32, config_file): assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( - 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' - 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' + 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' + 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np' + '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio=' ) -def test_list(capsys, config_file): +def test_list(capsys, config_file, nonexistent_config_file): """Tests list via the CLI interface""" with pytest.raises(SystemExit) as exit_info: - main(['-l']) + main(['-c', nonexistent_config_file, '-l']) assert exit_info.type == SystemExit assert exit_info.value.code == errno.EIO with pytest.raises(SystemExit) as exit_info: - main(['-c', f'{config_file}', '-l']) + main(['-c', config_file, '-l']) assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' @@ -104,10 +123,7 @@ def test_help(capsys): @unittest.mock.patch('os.urandom') @unittest.mock.patch('getpass.getpass') def test_generate_master_password_hash( - gpass, - urandom, - capsys, - non_random_bytes_32, + gpass, urandom, capsys, non_random_bytes_32 ): """Tests master password hash generation via the CLI interface""" urandom.return_value = non_random_bytes_32 @@ -117,8 +133,8 @@ def test_generate_master_password_hash( assert exit_info.type == SystemExit assert exit_info.value.code == 0 assert capsys.readouterr().out.strip() == ( - 'Master password hash: pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076' - 'CQj+hIFEgx+F4mn80o=$h3PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo=' + 'Master password hash: pbkdf2_sha256$500000$uYpZM1VfAGq0CDZL2duITs076' + 'CQj+hIFEgx+F4mn80o=$Msl8/5nOBj0TRchykMzXmCXR8VQVyBqUPHe1PDWeJi8=' ) diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py index 09baa31..73faed6 100644 --- a/tests/test_envtoolkit_instance.py +++ b/tests/test_envtoolkit_instance.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Tests for etoolkit.EtoolkitInstance""" + import pytest import etoolkit @@ -32,8 +33,9 @@ def test_instantiation(config_data): assert 'DB_CONNECTION' not in instance.sensitive_env_variables assert 'PASSWORD' in instance.sensitive_env_variables assert instance.name == 'secret' - assert instance.master_password_hash == ( - config_data['general']['MASTER_PASSWORD_HASH'] + assert ( + instance.master_password_hash + == (config_data['general']['MASTER_PASSWORD_HASH']) ) assert instance.master_password is None diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py index 73a5f36..5fb9451 100644 --- a/tests/test_envtoolkit_instance_static.py +++ b/tests/test_envtoolkit_instance_static.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Tests for etoolkit.EtoolkitInstance static methods""" + import unittest.mock import pytest @@ -35,7 +36,7 @@ def test_confirm_password_prompt(getpass, password_hash): ) -def test_decrypt(): +def test_decrypt_v1(): """Tests the static EtoolkitInstance.decrypt method""" assert ( etoolkit.EtoolkitInstance.decrypt( @@ -49,39 +50,118 @@ def test_decrypt(): ) # now test with modified edata + edata = ( + 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ5ZoAkurNgMYx' + 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' + ) with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: - edata = ( - 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ5ZoAkurNgMYx' - 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' + etoolkit.EtoolkitInstance.decrypt('The very secret passwd', edata) + assert exc_info.type is etoolkit.EtoolkitInstanceError + assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' + + +def test_decrypt_v2_no_padding(): + """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" + assert ( + etoolkit.EtoolkitInstance.decrypt( + 'The very secret passwd', + ( + 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$' + 'SQc0ox6Emf2m5rrumsiptpIZEujdpXXSR/' + '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8=' + ), ) + == 'Nobody expects the Spanish inquisition' + ) + + # now test with modified edata + edata = ( + 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$' + 'SQc0ox6Emf2m4rrumsiptpIZEujdpXXSR/' + '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8=' + ) + with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: + etoolkit.EtoolkitInstance.decrypt('The very secret passwd', 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(): + """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" + assert ( etoolkit.EtoolkitInstance.decrypt( - 'The very secret passwd', edata - ) == 'secret1' + 'The very secret passwd', + ( + 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$' + 'rhRona4wP9nhnXjcHqwkjFDsiVVVjYanAs' + 'N4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w=' + ), + ) + == 'secret1' + ) + + # now test with modified edata + edata = ( + 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$' + 'rhRona4wP8nhnXjcHqwkjFDsiVVVjYanAsN4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w=' + ) + with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: + etoolkit.EtoolkitInstance.decrypt('The very secret passwd', edata) assert exc_info.type is etoolkit.EtoolkitInstanceError assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' -def test_encrypt(): - """Tests the static EtoolkitInstance.encrypt method""" +def test_encrypt_no_padding(): + """Tests the static EtoolkitInstance.encrypt method with a long string""" + edata = etoolkit.EtoolkitInstance.encrypt( + 'foo', 'Nobody expects the Spanish inquisition' + ) + 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( + 'foo', 'Nobody expects the Spanish inquisition' + ) + + +def test_encrypt_with_padding(): + """Tests the static EtoolkitInstance.encrypt method with a short string""" edata = etoolkit.EtoolkitInstance.encrypt('foo', 'bar') - assert edata.startswith('enc-val$') - assert len(edata) == 83 + 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('foo', 'bar') @unittest.mock.patch('os.urandom') -def test_encrypt_staticly(urandom, non_random_bytes_32): +def test_encrypt_staticly_no_padding(urandom, non_random_bytes_32): """Tests the EtoolkitInstance.encrypt method always with the same salt""" urandom.return_value = non_random_bytes_32 + edata = etoolkit.EtoolkitInstance.encrypt( + 'The very secret passwd', 'Nobody expects the Spanish inquisition' + ) + assert edata == ( + 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$' + 'UX/5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmC' + 'o8hFSujpe35X/XpzAiYv4BV1LNwnSYECsotsgs=' + ) + assert len(edata) == 131 + assert edata == etoolkit.EtoolkitInstance.encrypt( + 'The very secret passwd', 'Nobody expects the Spanish inquisition' + ) + + +@unittest.mock.patch('os.urandom') +def test_encrypt_staticly_with_padding(urandom, non_random_bytes_61): + """Tests the EtoolkitInstance.encrypt method always with the same salt""" + urandom.return_value = non_random_bytes_61 edata = etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'bar') assert edata == ( - 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80' - 'o=$HjPFNv6xC5hbMrFc0L5lSkWdfQ==' + 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' + '+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/ESvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' ) assert edata == etoolkit.EtoolkitInstance.encrypt( - 'The very secret passwd', - 'bar', + 'The very secret passwd', 'bar' ) @@ -91,7 +171,7 @@ def test_get_new_password_hash(): 'The very secret passwd' ) # all pbkdf2 params are the same / hardcoded for the time being - assert new_hash.startswith('pbkdf2_sha256$100000$') + assert new_hash.startswith('pbkdf2_sha256$500000$') assert len(new_hash) == 110 # the hash should always be different because of random salting assert new_hash != etoolkit.EtoolkitInstance.get_new_password_hash( -- cgit v1.3