summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2024-04-30 11:39:20 +0200
committerSimeon Simeonov2024-04-30 11:39:20 +0200
commitc31fa58c85e866b3a5ab04882c7aff655f0b5477 (patch)
tree84532e1b6ce24c07267d4a8d8e8f810ba657500c
parent8ca7a0d9666ede2d9d270e2a469e4b8dda492e69 (diff)
Implement etoolkit encryption protocol v2
-rw-r--r--README.md4
-rw-r--r--pyproject.toml3
-rw-r--r--setup.cfg5
-rw-r--r--src/etoolkit/__init__.py5
-rw-r--r--src/etoolkit/__main__.py23
-rw-r--r--[-rwxr-xr-x]src/etoolkit/etoolkit.py80
-rw-r--r--tests/conftest.py35
-rw-r--r--tests/test_cli.py60
-rw-r--r--tests/test_envtoolkit_instance.py8
-rw-r--r--tests/test_envtoolkit_instance_static.py114
10 files changed, 248 insertions, 89 deletions
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:
21 21
22## Requirements 22## Requirements
23 23
24Apart from Python >= 3.7, the only requirement is 24Apart from Python >= 3.8, the only requirement is
25[cryptography](https://pypi.org/project/cryptography/) 25[cryptography](https://pypi.org/project/cryptography/)
26 26
27 27
@@ -317,7 +317,7 @@ Simeon Simeonov - sgs @ LiberaChat
317 317
318## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) 318## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE)
319 319
320Copyright (C) 2021-2022 Simeon Simeonov 320Copyright (C) 2021-2024 Simeon Simeonov
321All rights reserved. 321All rights reserved.
322 322
323[Licensed](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) under the 323[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"
13testpaths = [ 13testpaths = [
14 "tests" 14 "tests"
15] 15]
16pythonpath = [
17 "src"
18]
diff --git a/setup.cfg b/setup.cfg
index c218df8..4985370 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,11 +15,12 @@ classifiers =
15 Intended Audience :: System Administrators 15 Intended Audience :: System Administrators
16 License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) 16 License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
17 Programming Language :: Python :: 3 17 Programming Language :: Python :: 3
18 Programming Language :: Python :: 3.7
19 Programming Language :: Python :: 3.8 18 Programming Language :: Python :: 3.8
20 Programming Language :: Python :: 3.9 19 Programming Language :: Python :: 3.9
21 Programming Language :: Python :: 3.10 20 Programming Language :: Python :: 3.10
22 Programming Language :: Python :: 3.11 21 Programming Language :: Python :: 3.11
22 Programming Language :: Python :: 3.12
23 Programming Language :: Python :: 3.13
23 Operating System :: POSIX 24 Operating System :: POSIX
24 Topic :: Security :: Cryptography 25 Topic :: Security :: Cryptography
25 26
@@ -31,7 +32,7 @@ project_urls =
31package_dir = 32package_dir =
32 = src 33 = src
33packages = find: 34packages = find:
34python_requires = >=3.7 35python_requires = >=3.8
35 36
36install_requires = 37install_requires =
37 cryptography>=3.2 38 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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,10 +14,11 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""A simple toolkit for setting environment variables in a flexible way""" 16"""A simple toolkit for setting environment variables in a flexible way"""
17
17from .etoolkit import EtoolkitInstance, EtoolkitInstanceError 18from .etoolkit import EtoolkitInstance, EtoolkitInstanceError
18 19
19__author__ = 'Simeon Simeonov' 20__author__ = 'Simeon Simeonov'
20__version__ = '1.2.0' 21__version__ = '1.3.0'
21__license__ = 'GPL3' 22__license__ = 'GPL3'
22 23
23 24
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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -16,11 +16,14 @@
16""" 16"""
17CLI entry point for the etoolkit package 17CLI entry point for the etoolkit package
18 18
19Examples: 19Examples
20--------
20python -m etoolkit -h 21python -m etoolkit -h
21 22
22python -m etoolkit -p 23python -m etoolkit -p
24
23""" 25"""
26
24import argparse 27import argparse
25import errno 28import errno
26import getpass 29import getpass
@@ -33,7 +36,7 @@ import sys
33 36
34import etoolkit 37import etoolkit
35 38
36DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" 39DEFAULT_LOG_FORMAT = '%(levelname)s: %(message)s'
37DEFAULT_LOG_LEVEL = logging.WARNING 40DEFAULT_LOG_LEVEL = logging.WARNING
38 41
39logger = logging.getLogger(__name__) 42logger = logging.getLogger(__name__)
@@ -269,7 +272,7 @@ def main(inargs=None):
269 ) 272 )
270 args = parser.parse_args(inargs) 273 args = parser.parse_args(inargs)
271 try: 274 try:
272 with io.open(args.config_file, 'r', encoding='utf-8') as fp: 275 with io.open(args.config_file, encoding='utf-8') as fp:
273 config_dict = json.load(fp) 276 config_dict = json.load(fp)
274 except FileNotFoundError as e: 277 except FileNotFoundError as e:
275 # do not raise exception if config-file is missing for: 278 # do not raise exception if config-file is missing for:
@@ -278,16 +281,16 @@ def main(inargs=None):
278 # - password hash generation 281 # - password hash generation
279 if args.password_hash or args.decrypt_value or args.encrypt_value: 282 if args.password_hash or args.decrypt_value or args.encrypt_value:
280 logger.warning( 283 logger.warning(
281 "Configuration file %s is missing, although not required " 284 'Configuration file %s is missing, although not required '
282 "by the provided parameters", 285 'by the provided parameters',
283 args.config_file, 286 args.config_file,
284 ) 287 )
285 config_dict = {} 288 config_dict = {}
286 else: 289 else:
287 logger.error("Configuration file %s is missing", args.config_file) 290 logger.error('Configuration file %s is missing', args.config_file)
288 raise SystemExit(errno.EIO) from e 291 raise SystemExit(errno.EIO) from e
289 except Exception as e: 292 except Exception as e:
290 logger.error("Unable to parse %r: %s", args.config_file, e) 293 logger.exception('Unable to parse %r', args.config_file)
291 raise SystemExit(errno.EIO) from e 294 raise SystemExit(errno.EIO) from e
292 try: 295 try:
293 if args.decrypt_value: 296 if args.decrypt_value:
@@ -338,8 +341,8 @@ def main(inargs=None):
338 except subprocess.CalledProcessError as e: 341 except subprocess.CalledProcessError as e:
339 logger.error('Unable to spawn shell process: %s', e) 342 logger.error('Unable to spawn shell process: %s', e)
340 sys.exit(1) 343 sys.exit(1)
341 except Exception as e: 344 except Exception:
342 logger.error('Unexpected exception: %s', e) 345 logger.exception('Unexpected exception')
343 sys.exit(1) 346 sys.exit(1)
344 347
345 348
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py
index aab42a0..870a4be 100755..100644
--- a/src/etoolkit/etoolkit.py
+++ b/src/etoolkit/etoolkit.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,6 +14,7 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""The main module of the etoolkit package""" 16"""The main module of the etoolkit package"""
17
17import base64 18import base64
18import getpass 19import getpass
19import hashlib 20import hashlib
@@ -23,6 +24,9 @@ from cryptography.exceptions import InvalidTag
23from cryptography.hazmat.primitives.ciphers.aead import AESGCM 24from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24 25
25 26
27MIN_ENCRYPTED_VALUE_LENGTH = 32
28
29
26class EtoolkitInstanceError(Exception): 30class EtoolkitInstanceError(Exception):
27 """EtoolkitInstanceError - Generic exceptions related to instances""" 31 """EtoolkitInstanceError - Generic exceptions related to instances"""
28 32
@@ -175,24 +179,33 @@ class EtoolkitInstance:
175 :rtype: str 179 :rtype: str
176 """ 180 """
177 # check for supported versions 181 # check for supported versions
178 if not edata.startswith('enc-val$1$'): 182 if not edata.startswith(('enc-val$1$', 'enc-val$2$')):
179 raise EtoolkitInstanceError( 183 raise EtoolkitInstanceError(
180 f'Unsupported encryption format: {edata}' 184 f'Unsupported encryption format: {edata}'
181 ) 185 )
182 try: 186 try:
183 salt, data = [base64.b64decode(t) for t in edata[10:].split('$')] 187 salt, data = (base64.b64decode(t) for t in edata[10:].split('$'))
184 nonce = salt[:12] 188 nonce = salt[:12]
185 aesgcm = AESGCM( 189 aesgcm = AESGCM(
186 hashlib.scrypt( 190 hashlib.scrypt(
187 password.encode('utf-8'), 191 password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32
188 salt=salt,
189 n=2**14,
190 r=8,
191 p=1,
192 dklen=32,
193 ) 192 )
194 ) 193 )
195 return aesgcm.decrypt(nonce, data, salt).decode() 194
195 # decrypt
196 data = aesgcm.decrypt(nonce, data, salt)
197
198 if edata.startswith('enc-val$2$'):
199 # exclusively for the v2 data format:
200 # padding_length_bytes(2 bytes) data padding (between 0 and 32)
201
202 # extract padding_length_bytes
203 if data[:2] == b'--':
204 data = data[2:]
205 else:
206 data = data[2 : -int(data[:2].decode())]
207
208 return data.decode()
196 except InvalidTag as e: 209 except InvalidTag as e:
197 raise EtoolkitInstanceError( 210 raise EtoolkitInstanceError(
198 f'Invalid tag when decrypting: {edata}' 211 f'Invalid tag when decrypting: {edata}'
@@ -207,6 +220,8 @@ class EtoolkitInstance:
207 """ 220 """
208 Encrypts `data` using `password`. 221 Encrypts `data` using `password`.
209 222
223 Version 2 of the etoolkit encryption format
224
210 The output string is in the following format: 225 The output string is in the following format:
211 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` 226 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
212 227
@@ -219,21 +234,37 @@ class EtoolkitInstance:
219 :return: The output string 234 :return: The output string
220 :rtype: str 235 :rtype: str
221 """ 236 """
222 salt = os.urandom(32) 237 data_bytes = data.encode()
223 aesgcm = AESGCM( 238 if len(data_bytes) < MIN_ENCRYPTED_VALUE_LENGTH:
224 hashlib.scrypt( 239 padding_length = MIN_ENCRYPTED_VALUE_LENGTH - len(data_bytes)
225 password.encode('utf-8'), 240 rnd_bytes = os.urandom(32 + padding_length)
226 salt=salt, 241 salt = rnd_bytes[:32]
227 n=2**14, 242 aesgcm = AESGCM(
228 r=8, 243 hashlib.scrypt(
229 p=1, 244 password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32
230 dklen=32, 245 )
246 )
247 nonce = rnd_bytes[:12]
248 padding_bytes = rnd_bytes[32:]
249 # padding_length_bytes is always 2 bytes
250 padding_length_bytes = f'{padding_length:02d}'.encode()
251 edata = aesgcm.encrypt(
252 nonce, padding_length_bytes + data_bytes + padding_bytes, salt
253 )
254 else:
255 salt = os.urandom(32)
256 aesgcm = AESGCM(
257 hashlib.scrypt(
258 password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32
259 )
260 )
261 nonce = salt[:12]
262 padding_length_bytes = b'--' # no padding used 2 bytes "sign"
263 edata = aesgcm.encrypt(
264 nonce, padding_length_bytes + data_bytes, salt
231 ) 265 )
232 )
233 nonce = salt[:12]
234 edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt)
235 return ( 266 return (
236 f'enc-val$1${base64.b64encode(salt).decode()}$' 267 f'enc-val$2${base64.b64encode(salt).decode()}$'
237 f'{base64.b64encode(edata).decode()}' 268 f'{base64.b64encode(edata).decode()}'
238 ) 269 )
239 270
@@ -252,7 +283,7 @@ class EtoolkitInstance:
252 :rtype: str 283 :rtype: str
253 """ 284 """
254 hash_algo = 'sha256' 285 hash_algo = 'sha256'
255 iterations = 100000 286 iterations = 500000
256 salt = os.urandom(32) 287 salt = os.urandom(32)
257 key = hashlib.pbkdf2_hmac( 288 key = hashlib.pbkdf2_hmac(
258 hash_algo, password.encode('utf-8'), salt, iterations 289 hash_algo, password.encode('utf-8'), salt, iterations
@@ -339,7 +370,6 @@ class EtoolkitInstance:
339 macros = { 370 macros = {
340 '%h': os.path.expanduser('~'), 371 '%h': os.path.expanduser('~'),
341 '%i': self.name, 372 '%i': self.name,
342 # '%f': self.get_full_name(),
343 '%u': getpass.getuser(), 373 '%u': getpass.getuser(),
344 } 374 }
345 new_env = {} 375 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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,14 +14,16 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""Common fixtures""" 16"""Common fixtures"""
17
17import json 18import json
18 19
19import pytest 20import pytest
20 21
21 22
22@pytest.fixture 23@pytest.fixture()
23def config_data(): 24def config_data():
24 """config_data for testing""" 25 """config_data for testing"""
26
25 return { 27 return {
26 'general': { 28 'general': {
27 'MASTER_PASSWORD_HASH': ( 29 'MASTER_PASSWORD_HASH': (
@@ -51,26 +53,47 @@ def config_data():
51 } 53 }
52 54
53 55
54@pytest.fixture 56@pytest.fixture()
55def config_file(tmp_path, config_data): 57def config_file(tmp_path, config_data):
56 """temporary config file for testing that includes config_data""" 58 """temporary config file for testing that includes config_data"""
57 cf = tmp_path / "etoolkit.json" 59
60 cf = tmp_path / 'etoolkit.json'
58 cf.write_text(json.dumps(config_data)) 61 cf.write_text(json.dumps(config_data))
59 return str(cf) 62 return str(cf)
60 63
61 64
62@pytest.fixture 65@pytest.fixture()
63def non_random_bytes_32(): 66def non_random_bytes_32():
64 """always use the same bytes instead of os.urandom(32)""" 67 """always use the same bytes instead of os.urandom(32)"""
68
65 return ( 69 return (
66 b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N' 70 b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N'
67 b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J' 71 b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J'
68 ) 72 )
69 73
70 74
71@pytest.fixture 75@pytest.fixture()
76def non_random_bytes_61():
77 """always use the same bytes instead of os.urandom(61)"""
78
79 return (
80 b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa'
81 b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e'
82 b'\xa4s(Q\x04\xcd\xc7T\x01_D\xb1'
83 )
84
85
86@pytest.fixture()
87def nonexistent_config_file(tmp_path):
88 """temporary config file for testing that includes config_data"""
89
90 return str(tmp_path / 'etoolkitt.json')
91
92
93@pytest.fixture()
72def password_hash(): 94def password_hash():
73 """password hash for testing, corresponding to 'The very secret passwd'""" 95 """password hash for testing, corresponding to 'The very secret passwd'"""
96
74 return ( 97 return (
75 'pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$h3' 98 'pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$h3'
76 'PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo=' 99 '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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,6 +14,7 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""Tests for the CLI (etoolkit.__main__""" 16"""Tests for the CLI (etoolkit.__main__"""
17
17import errno 18import errno
18import os 19import os
19import unittest.mock 20import unittest.mock
@@ -24,11 +25,9 @@ import etoolkit
24from etoolkit.__main__ import main 25from etoolkit.__main__ import main
25 26
26 27
27@unittest.mock.patch('os.urandom')
28@unittest.mock.patch('builtins.input') 28@unittest.mock.patch('builtins.input')
29def test_decrypt(binput, urandom, capsys, non_random_bytes_32, config_file): 29def test_decrypt_v1(binput, capsys, config_file):
30 """Tests encryption via the CLI interface""" 30 """Tests v1 decryption via the CLI interface"""
31 urandom.return_value = non_random_bytes_32
32 binput.return_value = ( 31 binput.return_value = (
33 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' 32 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+'
34 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' 33 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw=='
@@ -43,11 +42,29 @@ def test_decrypt(binput, urandom, capsys, non_random_bytes_32, config_file):
43 assert capsys.readouterr().out.strip() == 'Decrypted value: bar' 42 assert capsys.readouterr().out.strip() == 'Decrypted value: bar'
44 43
45 44
45@unittest.mock.patch('builtins.input')
46def test_decrypt_v2(binput, capsys, config_file):
47 """Tests v2 decryption via the CLI interface"""
48 binput.return_value = (
49 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq'
50 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np'
51 '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio='
52 )
53 with unittest.mock.patch.dict(
54 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
55 ):
56 with pytest.raises(SystemExit) as exit_info:
57 main(['-c', f'{config_file}', '-d'])
58 assert exit_info.type == SystemExit
59 assert exit_info.value.code == 0
60 assert capsys.readouterr().out.strip() == 'Decrypted value: bar'
61
62
46@unittest.mock.patch('os.urandom') 63@unittest.mock.patch('os.urandom')
47@unittest.mock.patch('builtins.input', lambda *args: 'bar') 64@unittest.mock.patch('builtins.input', lambda *args: 'bar')
48def test_encrypt_with_echo(urandom, capsys, non_random_bytes_32, config_file): 65def test_encrypt_with_echo(urandom, capsys, non_random_bytes_61, config_file):
49 """Tests encryption via the CLI interface""" 66 """Tests encryption via the CLI interface"""
50 urandom.return_value = non_random_bytes_32 67 urandom.return_value = non_random_bytes_61
51 with unittest.mock.patch.dict( 68 with unittest.mock.patch.dict(
52 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'} 69 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
53 ): 70 ):
@@ -56,16 +73,17 @@ def test_encrypt_with_echo(urandom, capsys, non_random_bytes_32, config_file):
56 assert exit_info.type == SystemExit 73 assert exit_info.type == SystemExit
57 assert exit_info.value.code == 0 74 assert exit_info.value.code == 0
58 assert capsys.readouterr().out.strip() == ( 75 assert capsys.readouterr().out.strip() == (
59 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' 76 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq'
60 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' 77 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np'
78 '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio='
61 ) 79 )
62 80
63 81
64@unittest.mock.patch('os.urandom') 82@unittest.mock.patch('os.urandom')
65@unittest.mock.patch('getpass.getpass', lambda *args: 'bar') 83@unittest.mock.patch('getpass.getpass', lambda *args: 'bar')
66def test_encrypt_without_echo(gpass, capsys, non_random_bytes_32, config_file): 84def test_encrypt_without_echo(gpass, capsys, non_random_bytes_61, config_file):
67 """Tests encryption via the CLI interface""" 85 """Tests encryption via the CLI interface"""
68 gpass.return_value = non_random_bytes_32 86 gpass.return_value = non_random_bytes_61
69 with unittest.mock.patch.dict( 87 with unittest.mock.patch.dict(
70 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'} 88 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
71 ): 89 ):
@@ -74,19 +92,20 @@ def test_encrypt_without_echo(gpass, capsys, non_random_bytes_32, config_file):
74 assert exit_info.type == SystemExit 92 assert exit_info.type == SystemExit
75 assert exit_info.value.code == 0 93 assert exit_info.value.code == 0
76 assert capsys.readouterr().out.strip() == ( 94 assert capsys.readouterr().out.strip() == (
77 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+' 95 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq'
78 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw==' 96 'rLTBxM=$VW3UZ6l12yDtyaqWHb7i0QEDiS9s9np'
97 '7huAACK54BtZVV7RZoIhbu4K6zZuz+LRCyio='
79 ) 98 )
80 99
81 100
82def test_list(capsys, config_file): 101def test_list(capsys, config_file, nonexistent_config_file):
83 """Tests list via the CLI interface""" 102 """Tests list via the CLI interface"""
84 with pytest.raises(SystemExit) as exit_info: 103 with pytest.raises(SystemExit) as exit_info:
85 main(['-l']) 104 main(['-c', nonexistent_config_file, '-l'])
86 assert exit_info.type == SystemExit 105 assert exit_info.type == SystemExit
87 assert exit_info.value.code == errno.EIO 106 assert exit_info.value.code == errno.EIO
88 with pytest.raises(SystemExit) as exit_info: 107 with pytest.raises(SystemExit) as exit_info:
89 main(['-c', f'{config_file}', '-l']) 108 main(['-c', config_file, '-l'])
90 assert exit_info.type == SystemExit 109 assert exit_info.type == SystemExit
91 assert exit_info.value.code == 0 110 assert exit_info.value.code == 0
92 assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret' 111 assert capsys.readouterr().out.strip() == f'dev{os.linesep}secret'
@@ -104,10 +123,7 @@ def test_help(capsys):
104@unittest.mock.patch('os.urandom') 123@unittest.mock.patch('os.urandom')
105@unittest.mock.patch('getpass.getpass') 124@unittest.mock.patch('getpass.getpass')
106def test_generate_master_password_hash( 125def test_generate_master_password_hash(
107 gpass, 126 gpass, urandom, capsys, non_random_bytes_32
108 urandom,
109 capsys,
110 non_random_bytes_32,
111): 127):
112 """Tests master password hash generation via the CLI interface""" 128 """Tests master password hash generation via the CLI interface"""
113 urandom.return_value = non_random_bytes_32 129 urandom.return_value = non_random_bytes_32
@@ -117,8 +133,8 @@ def test_generate_master_password_hash(
117 assert exit_info.type == SystemExit 133 assert exit_info.type == SystemExit
118 assert exit_info.value.code == 0 134 assert exit_info.value.code == 0
119 assert capsys.readouterr().out.strip() == ( 135 assert capsys.readouterr().out.strip() == (
120 'Master password hash: pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076' 136 'Master password hash: pbkdf2_sha256$500000$uYpZM1VfAGq0CDZL2duITs076'
121 'CQj+hIFEgx+F4mn80o=$h3PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo=' 137 'CQj+hIFEgx+F4mn80o=$Msl8/5nOBj0TRchykMzXmCXR8VQVyBqUPHe1PDWeJi8='
122 ) 138 )
123 139
124 140
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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,6 +14,7 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""Tests for etoolkit.EtoolkitInstance""" 16"""Tests for etoolkit.EtoolkitInstance"""
17
17import pytest 18import pytest
18 19
19import etoolkit 20import etoolkit
@@ -32,8 +33,9 @@ def test_instantiation(config_data):
32 assert 'DB_CONNECTION' not in instance.sensitive_env_variables 33 assert 'DB_CONNECTION' not in instance.sensitive_env_variables
33 assert 'PASSWORD' in instance.sensitive_env_variables 34 assert 'PASSWORD' in instance.sensitive_env_variables
34 assert instance.name == 'secret' 35 assert instance.name == 'secret'
35 assert instance.master_password_hash == ( 36 assert (
36 config_data['general']['MASTER_PASSWORD_HASH'] 37 instance.master_password_hash
38 == (config_data['general']['MASTER_PASSWORD_HASH'])
37 ) 39 )
38 assert instance.master_password is None 40 assert instance.master_password is None
39 41
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 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov 2# Copyright (C) 2021-2024 Simeon Simeonov
3 3
4# This program is free software: you can redistribute it and/or modify 4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by 5# it under the terms of the GNU General Public License as published by
@@ -14,6 +14,7 @@
14# You should have received a copy of the GNU General Public License 14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>. 15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16"""Tests for etoolkit.EtoolkitInstance static methods""" 16"""Tests for etoolkit.EtoolkitInstance static methods"""
17
17import unittest.mock 18import unittest.mock
18 19
19import pytest 20import pytest
@@ -35,7 +36,7 @@ def test_confirm_password_prompt(getpass, password_hash):
35 ) 36 )
36 37
37 38
38def test_decrypt(): 39def test_decrypt_v1():
39 """Tests the static EtoolkitInstance.decrypt method""" 40 """Tests the static EtoolkitInstance.decrypt method"""
40 assert ( 41 assert (
41 etoolkit.EtoolkitInstance.decrypt( 42 etoolkit.EtoolkitInstance.decrypt(
@@ -49,39 +50,118 @@ def test_decrypt():
49 ) 50 )
50 51
51 # now test with modified edata 52 # now test with modified edata
53 edata = (
54 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ5ZoAkurNgMYx'
55 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY='
56 )
52 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: 57 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
53 edata = ( 58 etoolkit.EtoolkitInstance.decrypt('The very secret passwd', edata)
54 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ5ZoAkurNgMYx' 59 assert exc_info.type is etoolkit.EtoolkitInstanceError
55 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY=' 60 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
61
62
63def test_decrypt_v2_no_padding():
64 """Tests the static EtoolkitInstance.decrypt method for v2 - no padding"""
65 assert (
66 etoolkit.EtoolkitInstance.decrypt(
67 'The very secret passwd',
68 (
69 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$'
70 'SQc0ox6Emf2m5rrumsiptpIZEujdpXXSR/'
71 '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8='
72 ),
56 ) 73 )
74 == 'Nobody expects the Spanish inquisition'
75 )
76
77 # now test with modified edata
78 edata = (
79 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$'
80 'SQc0ox6Emf2m4rrumsiptpIZEujdpXXSR/'
81 '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8='
82 )
83 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
84 etoolkit.EtoolkitInstance.decrypt('The very secret passwd', edata)
85 assert exc_info.type is etoolkit.EtoolkitInstanceError
86 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
87
88
89def test_decrypt_v2_with_padding():
90 """Tests the static EtoolkitInstance.decrypt method for v2 with padding"""
91 assert (
57 etoolkit.EtoolkitInstance.decrypt( 92 etoolkit.EtoolkitInstance.decrypt(
58 'The very secret passwd', edata 93 'The very secret passwd',
59 ) == 'secret1' 94 (
95 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$'
96 'rhRona4wP9nhnXjcHqwkjFDsiVVVjYanAs'
97 'N4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w='
98 ),
99 )
100 == 'secret1'
101 )
102
103 # now test with modified edata
104 edata = (
105 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$'
106 'rhRona4wP8nhnXjcHqwkjFDsiVVVjYanAsN4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w='
107 )
108 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
109 etoolkit.EtoolkitInstance.decrypt('The very secret passwd', edata)
60 assert exc_info.type is etoolkit.EtoolkitInstanceError 110 assert exc_info.type is etoolkit.EtoolkitInstanceError
61 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' 111 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
62 112
63 113
64def test_encrypt(): 114def test_encrypt_no_padding():
65 """Tests the static EtoolkitInstance.encrypt method""" 115 """Tests the static EtoolkitInstance.encrypt method with a long string"""
116 edata = etoolkit.EtoolkitInstance.encrypt(
117 'foo', 'Nobody expects the Spanish inquisition'
118 )
119 assert edata.startswith('enc-val$2$')
120 assert len(edata) == 131
121 # the edata should always be different because of random salting
122 assert edata != etoolkit.EtoolkitInstance.encrypt(
123 'foo', 'Nobody expects the Spanish inquisition'
124 )
125
126
127def test_encrypt_with_padding():
128 """Tests the static EtoolkitInstance.encrypt method with a short string"""
66 edata = etoolkit.EtoolkitInstance.encrypt('foo', 'bar') 129 edata = etoolkit.EtoolkitInstance.encrypt('foo', 'bar')
67 assert edata.startswith('enc-val$') 130 assert edata.startswith('enc-val$2$')
68 assert len(edata) == 83 131 assert len(edata) == 123
69 # the edata should always be different because of random salting 132 # the edata should always be different because of random salting
70 assert edata != etoolkit.EtoolkitInstance.encrypt('foo', 'bar') 133 assert edata != etoolkit.EtoolkitInstance.encrypt('foo', 'bar')
71 134
72 135
73@unittest.mock.patch('os.urandom') 136@unittest.mock.patch('os.urandom')
74def test_encrypt_staticly(urandom, non_random_bytes_32): 137def test_encrypt_staticly_no_padding(urandom, non_random_bytes_32):
75 """Tests the EtoolkitInstance.encrypt method always with the same salt""" 138 """Tests the EtoolkitInstance.encrypt method always with the same salt"""
76 urandom.return_value = non_random_bytes_32 139 urandom.return_value = non_random_bytes_32
140 edata = etoolkit.EtoolkitInstance.encrypt(
141 'The very secret passwd', 'Nobody expects the Spanish inquisition'
142 )
143 assert edata == (
144 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$'
145 'UX/5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmC'
146 'o8hFSujpe35X/XpzAiYv4BV1LNwnSYECsotsgs='
147 )
148 assert len(edata) == 131
149 assert edata == etoolkit.EtoolkitInstance.encrypt(
150 'The very secret passwd', 'Nobody expects the Spanish inquisition'
151 )
152
153
154@unittest.mock.patch('os.urandom')
155def test_encrypt_staticly_with_padding(urandom, non_random_bytes_61):
156 """Tests the EtoolkitInstance.encrypt method always with the same salt"""
157 urandom.return_value = non_random_bytes_61
77 edata = etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'bar') 158 edata = etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'bar')
78 assert edata == ( 159 assert edata == (
79 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80' 160 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$'
80 'o=$HjPFNv6xC5hbMrFc0L5lSkWdfQ==' 161 '+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/ESvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4='
81 ) 162 )
82 assert edata == etoolkit.EtoolkitInstance.encrypt( 163 assert edata == etoolkit.EtoolkitInstance.encrypt(
83 'The very secret passwd', 164 'The very secret passwd', 'bar'
84 'bar',
85 ) 165 )
86 166
87 167
@@ -91,7 +171,7 @@ def test_get_new_password_hash():
91 'The very secret passwd' 171 'The very secret passwd'
92 ) 172 )
93 # all pbkdf2 params are the same / hardcoded for the time being 173 # all pbkdf2 params are the same / hardcoded for the time being
94 assert new_hash.startswith('pbkdf2_sha256$100000$') 174 assert new_hash.startswith('pbkdf2_sha256$500000$')
95 assert len(new_hash) == 110 175 assert len(new_hash) == 110
96 # the hash should always be different because of random salting 176 # the hash should always be different because of random salting
97 assert new_hash != etoolkit.EtoolkitInstance.get_new_password_hash( 177 assert new_hash != etoolkit.EtoolkitInstance.get_new_password_hash(