summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2022-03-04 20:48:20 +0100
committerSimeon Simeonov2022-03-04 20:48:20 +0100
commit907f80e1889781a69edded00126b3e78ddeb7b8e (patch)
tree20be15e81762d7a72e4caa40bda8b4301339efe4
parent4914b0c74ed669d6631897950ff1660ce91ef918 (diff)
Add the -P param, bash completion script and improve the README.md
-rw-r--r--README.md33
-rw-r--r--completion/etoolkit.bash105
-rw-r--r--etoolkit/__init__.py4
-rw-r--r--etoolkit/__main__.py79
-rwxr-xr-xetoolkit/etoolkit.py6
-rw-r--r--tests/conftest.py31
-rw-r--r--tests/test_cli.py97
-rw-r--r--tests/test_envtoolkit_instance.py8
-rw-r--r--tests/test_envtoolkit_instance_static.py43
9 files changed, 363 insertions, 43 deletions
diff --git a/README.md b/README.md
index 5c543f4..218ea7c 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ file are encrypted with the same master password. Setting a master password
117hash is a recommended but not mandatory. 117hash is a recommended but not mandatory.
118 118
119 ```bash 119 ```bash
120 etoolkit -p 120 etoolkit --generate-master-password-hash
121 ``` 121 ```
122 122
123That command will prompt for master password and output a hash that can then 123That command will prompt for master password and output a hash that can then
@@ -127,7 +127,7 @@ The hash is only used for verifying that a correct master password has been
127provided at a later time. Issuing: 127provided at a later time. Issuing:
128 128
129 ```bash 129 ```bash
130 etoolkit -e 130 etoolkit --encrypt-value
131 ``` 131 ```
132 132
133will prompt for the master password, then for the value to be encrypted and 133will prompt for the master password, then for the value to be encrypted and
@@ -140,14 +140,24 @@ prompt if *-m* / *--multiple-values* parameter is provided. Manual decryption
140of single value(s): 140of single value(s):
141 141
142 ```bash 142 ```bash
143 etoolkit -d -m 143 etoolkit --decrypt-value --multiple-values
144 ``` 144 ```
145 145
146Another possibility is to pass the value to *etoolkit*'s *stdin* using a pipe.
147*etoolkit* will then only prompt for password and not for a value:
148
149 ```bash
150 echo mysecret | etoolkit --encrypt-value
151 ```
152
153... or if the *ETOOLKIT_MASTER_PASSWORD* env. variable is defined, its value
154will be used instead of prompting for password.
155
146Listing all available instances defined in the configuration file and then 156Listing all available instances defined in the configuration file and then
147loading a specific instance can be achieved by: 157loading a specific instance can be achieved by:
148 158
149 ```bash 159 ```bash
150 etoolkit -l 160 etoolkit --list
151 etoolkit <instance-name> 161 etoolkit <instance-name>
152 ``` 162 ```
153 163
@@ -178,7 +188,7 @@ One can also spawn a different process than an interactive shell by using the
178*-s* / *--spawn* parameter. 188*-s* / *--spawn* parameter.
179 189
180 ```bash 190 ```bash
181 etoolkit -s /bin/othershell <instance-name> 191 etoolkit --spawn /bin/othershell <instance-name>
182 ``` 192 ```
183 193
184Contact the author for questions and suggestions! :) 194Contact the author for questions and suggestions! :)
@@ -264,10 +274,19 @@ file (f.i. ~/.bashrc):
264 274
265 ```bash 275 ```bash
266 if [ -n "$ETOOLKIT_PROMPT" ]; then 276 if [ -n "$ETOOLKIT_PROMPT" ]; then
267 export PS1="$ETOOLKIT_PROMPT$PS1" 277 export PS1="$ETOOLKIT_PROMPT$PS1"
268 fi 278 fi
269 ``` 279 ```
270 280
281A quick and dirty bash completion for available instances can be set at the
282bottom of your bash startup file:
283
284 ```bash
285 complete -W '$(compgen -W "$(etoolkit -l)")' etoolkit
286 ```
287
288A complete bash completion script for *etoolkit* can be found here: [https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash](https://github.com/blackm0re/etoolkit/blob/master/completion/etoolkit.bash)
289
271 290
272## Support and contributing 291## Support and contributing
273 292
@@ -281,7 +300,7 @@ Simeon Simeonov - sgs @ LiberaChat
281 300
282## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) 301## [License](https://github.com/blackm0re/etoolkit/blob/master/LICENSE)
283 302
284Copyright (c) 2021, Simeon Simeonov 303Copyright (C) 2021-2022 Simeon Simeonov
285All rights reserved. 304All rights reserved.
286 305
287[Licensed](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) under the 306[Licensed](https://github.com/blackm0re/etoolkit/blob/master/LICENSE) under the
diff --git a/completion/etoolkit.bash b/completion/etoolkit.bash
new file mode 100644
index 0000000..2e80811
--- /dev/null
+++ b/completion/etoolkit.bash
@@ -0,0 +1,105 @@
1# etoolkit bash-completion
2# -*- shell-script -*-
3
4_instances() {
5
6 local instances
7
8 if [[ -n ${2} ]]; then
9 instances="$(etoolkit -c $2 -l 2> /dev/null)"
10 else
11 instances="$(etoolkit -l 2> /dev/null)"
12 fi
13
14 if [[ $? -eq 0 ]]; then
15 COMPREPLY+=($(compgen -W "$instances" -- "$1"))
16 fi
17
18}
19
20
21_etoolkit() {
22
23 local all_params config_file c cur i no_output numwords prev spawn
24 COMPREPLY=()
25 cur="${COMP_WORDS[COMP_CWORD]}"
26 prev="${COMP_WORDS[COMP_CWORD-1]}"
27 numwords=${#COMP_WORDS[*]}
28 no_output=0
29 spawn=0
30 all_params="-d --decrypt-value -e --encrypt-value -l --list -h --help
31 -P --master-password-prompt -p --generate-master-password-hash
32 -c --config-file -E --echo -m --multiple-values -q --no-output
33 -s --spawn -v --version"
34 # if [ ${prev:0:1} == "-" ]
35
36 if [ ${COMP_CWORD} -eq 1 ]; then
37 # first param
38 COMPREPLY=($(compgen -W "$all_params" -- "$cur"))
39 _instances "$cur"
40 return
41 else
42 # param >= 2
43 # handle all instance-relevant params
44 for ((i = 0; i < ${numwords} + 1; i++ )); do
45 c=${COMP_WORDS[${i}]}
46 if [[ ${c} == "-c" || ${c} == "--config-file" ]]; then
47 config_file="${COMP_WORDS[${i} + 1]}"
48 elif [[ ${c} == "-s" || ${c} == "--spawn" ]]; then
49 spawn=1
50 elif [[ ${c} == "-q" || ${c} == "--no-output" ]]; then
51 no_output=1
52 fi
53 done
54 fi
55
56 case $prev in
57 "-c" | "--config-file")
58 COMPREPLY=($(compgen -f -- "$cur"))
59 return
60 ;;
61 "-d" | "--decrypt-value")
62 COMPREPLY=($(compgen -W "-m --multiple-values -P --master-password-prompt" -- "$cur"))
63 return
64 ;;
65 "-e" | "--encrypt-value")
66 COMPREPLY=($(compgen -W "-E --echo -m --multiple-values -P --master-password-prompt" -- "$cur"))
67 return
68 ;;
69 "-E" | "--echo")
70 COMPREPLY=($(compgen -W "-e --encrypt-value -m --multiple-values -P --master-password-prompt" -- "$cur"))
71 return
72 ;;
73 "-m" | "--multiple-values")
74 COMPREPLY=($(compgen -W "-d --decrypt-value -E -e --echo --encrypt-value -P --master-password-prompt" -- "$cur"))
75 return
76 ;;
77 "-P" | "--master-password-prompt")
78 COMPREPLY=($(compgen -W "-d --decrypt-value -E -e --echo --encrypt-value -m --multiple-values" -- "$cur"))
79 return
80 ;;
81 "-s" | "--spawn")
82 COMPREPLY=($(compgen -c -- "$cur"))
83 return
84 ;;
85 esac
86
87 # assume instance and handle only instance params
88 if [[ -z ${config_file} ]]; then
89 COMPREPLY+=($(compgen -W "-c --config-file" -- "$cur"))
90 fi
91
92 if [[ ${spawn} -ne 1 ]]; then
93 COMPREPLY+=($(compgen -W "-s --spawn" -- "$cur"))
94 fi
95
96 if [[ ${no_output} -ne 1 ]]; then
97 COMPREPLY+=($(compgen -W "--no-output -q" -- "$cur"))
98 fi
99
100 _instances "$cur" "$config_file"
101
102}
103
104
105complete -F _etoolkit etoolkit
diff --git a/etoolkit/__init__.py b/etoolkit/__init__.py
index 9a6d9aa..df53a55 100644
--- a/etoolkit/__init__.py
+++ b/etoolkit/__init__.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021 Simeon Simeonov 2# Copyright (C) 2021-2022 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
@@ -17,7 +17,7 @@
17from .etoolkit import EtoolkitInstance, EtoolkitInstanceError 17from .etoolkit import EtoolkitInstance, EtoolkitInstanceError
18 18
19__author__ = 'Simeon Simeonov' 19__author__ = 'Simeon Simeonov'
20__version__ = '1.0.0' 20__version__ = '1.1.0-rc1'
21__license__ = 'GPL3' 21__license__ = 'GPL3'
22 22
23 23
diff --git a/etoolkit/__main__.py b/etoolkit/__main__.py
index 8c4245d..603bb2e 100644
--- a/etoolkit/__main__.py
+++ b/etoolkit/__main__.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021 Simeon Simeonov 2# Copyright (C) 2021-2022 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
@@ -24,6 +24,7 @@ python -m etoolkit -p
24import argparse 24import argparse
25import errno 25import errno
26import getpass 26import getpass
27import io
27import json 28import json
28import logging 29import logging
29import os 30import os
@@ -52,11 +53,29 @@ def decrypt_value(args: argparse.Namespace, config: dict):
52 :type config: dict 53 :type config: dict
53 """ 54 """
54 password_hash = None 55 password_hash = None
56 pipe_input = None
57 if not os.isatty(sys.stdin.fileno()):
58 pipe_input = sys.stdin.read().strip()
55 if 'general' in config: 59 if 'general' in config:
56 password_hash = config['general'].get('MASTER_PASSWORD_HASH') 60 password_hash = config['general'].get('MASTER_PASSWORD_HASH')
57 password = etoolkit.EtoolkitInstance.confirm_password_prompt( 61
58 password_hash, False 62 if (
59 ) 63 args.master_password_prompt
64 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
65 ):
66 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
67 password_hash, False
68 )
69 else:
70 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
71
72 if pipe_input:
73 # the input came from stdin. No need to prompt
74 print(
75 'Decrypted value: '
76 f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}'
77 )
78 return
60 while True: 79 while True:
61 try: 80 try:
62 value = input('Value: ') 81 value = input('Value: ')
@@ -69,6 +88,7 @@ def decrypt_value(args: argparse.Namespace, config: dict):
69 except KeyboardInterrupt: 88 except KeyboardInterrupt:
70 print(os.linesep) 89 print(os.linesep)
71 break 90 break
91 return
72 92
73 93
74def encrypt_value(args: argparse.Namespace, config: dict): 94def encrypt_value(args: argparse.Namespace, config: dict):
@@ -86,9 +106,29 @@ def encrypt_value(args: argparse.Namespace, config: dict):
86 :type config: dict 106 :type config: dict
87 """ 107 """
88 password_hash = None 108 password_hash = None
109 pipe_input = None
110 if not os.isatty(sys.stdin.fileno()):
111 pipe_input = sys.stdin.read().strip()
89 if 'general' in config: 112 if 'general' in config:
90 password_hash = config['general'].get('MASTER_PASSWORD_HASH') 113 password_hash = config['general'].get('MASTER_PASSWORD_HASH')
91 password = etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash) 114
115 if (
116 args.master_password_prompt
117 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
118 ):
119 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
120 password_hash
121 )
122 else:
123 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
124
125 if pipe_input:
126 # the input came from stdin. No need to prompt
127 print(
128 'Encrypted value: '
129 f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}'
130 )
131 return
92 while True: 132 while True:
93 try: 133 try:
94 if args.echo: 134 if args.echo:
@@ -104,6 +144,7 @@ def encrypt_value(args: argparse.Namespace, config: dict):
104 except KeyboardInterrupt: 144 except KeyboardInterrupt:
105 print(os.linesep) 145 print(os.linesep)
106 break 146 break
147 return
107 148
108 149
109def main(inargs=None): 150def main(inargs=None):
@@ -192,6 +233,16 @@ def main(inargs=None):
192 ), 233 ),
193 ) 234 )
194 parser.add_argument( 235 parser.add_argument(
236 '-P',
237 '--master-password-prompt',
238 dest='master_password_prompt',
239 action='store_true',
240 help=(
241 'Force prompt for the master password even if the env. variable '
242 '"ETOOLKIT_MASTER_PASSWORD" is set'
243 ),
244 )
245 parser.add_argument(
195 '-q', 246 '-q',
196 '--no-output', 247 '--no-output',
197 dest='dump_output', 248 dest='dump_output',
@@ -217,12 +268,26 @@ def main(inargs=None):
217 ) 268 )
218 args = parser.parse_args(inargs) 269 args = parser.parse_args(inargs)
219 try: 270 try:
220 with open(args.config_file, 'r', encoding='utf-8') as fp: 271 with io.open(args.config_file, 'r', encoding='utf-8') as fp:
221 config_dict = json.load(fp) 272 config_dict = json.load(fp)
273 except FileNotFoundError as e:
274 # do not raise exception if config-file is missing for:
275 # - decrypting value
276 # - encrypting value
277 # - password hash generation
278 if args.password_hash or args.decrypt_value or args.encrypt_value:
279 logger.warning(
280 "Configuration file %s is missing, although not required "
281 "by the provided parameters",
282 args.config_file,
283 )
284 config_dict = {}
285 else:
286 logger.error("Configuration file %s is missing", args.config_file)
287 raise SystemExit(errno.EIO) from e
222 except Exception as e: 288 except Exception as e:
223 logger.error("Unable to parse %r: %s", args.config_file, e) 289 logger.error("Unable to parse %r: %s", args.config_file, e)
224 raise SystemExit(errno.EIO) from e 290 raise SystemExit(errno.EIO) from e
225
226 try: 291 try:
227 if args.decrypt_value: 292 if args.decrypt_value:
228 decrypt_value(args, config_dict) 293 decrypt_value(args, config_dict)
diff --git a/etoolkit/etoolkit.py b/etoolkit/etoolkit.py
index 837d13a..d8221a5 100755
--- a/etoolkit/etoolkit.py
+++ b/etoolkit/etoolkit.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021 Simeon Simeonov 2# Copyright (C) 2021-2022 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
@@ -179,7 +179,7 @@ class EtoolkitInstance:
179 hashlib.scrypt( 179 hashlib.scrypt(
180 password.encode('utf-8'), 180 password.encode('utf-8'),
181 salt=salt, 181 salt=salt,
182 n=2 ** 14, 182 n=2**14,
183 r=8, 183 r=8,
184 p=1, 184 p=1,
185 dklen=32, 185 dklen=32,
@@ -217,7 +217,7 @@ class EtoolkitInstance:
217 hashlib.scrypt( 217 hashlib.scrypt(
218 password.encode('utf-8'), 218 password.encode('utf-8'),
219 salt=salt, 219 salt=salt,
220 n=2 ** 14, 220 n=2**14,
221 r=8, 221 r=8,
222 p=1, 222 p=1,
223 dklen=32, 223 dklen=32,
diff --git a/tests/conftest.py b/tests/conftest.py
index 1f70259..0770adb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021 Simeon Simeonov 2# Copyright (C) 2021-2022 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,8 @@
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"""
17import json
18
17import pytest 19import pytest
18 20
19 21
@@ -23,8 +25,8 @@ def config_data():
23 return { 25 return {
24 'general': { 26 'general': {
25 'MASTER_PASSWORD_HASH': ( 27 'MASTER_PASSWORD_HASH': (
26 'pbkdf2_sha256$100000$kFOQkAPtStZ/Ny/O4501ygHGQnqh5Y+ySxF9qVHr' 28 'pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4m'
27 'iv8=$3BujuWzn3CfDnw4yiD9m3F+GjeW1MHHW40R/ThHNcn0=' 29 'n80o=$h3PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo='
28 ) 30 )
29 }, 31 },
30 'instances': { 32 'instances': {
@@ -51,9 +53,26 @@ def config_data():
51 53
52 54
53@pytest.fixture 55@pytest.fixture
56def config_file(tmp_path, config_data):
57 """temporary config file for testing that includes config_data"""
58 cf = tmp_path / "etoolkit.json"
59 cf.write_text(json.dumps(config_data))
60 return str(cf)
61
62
63@pytest.fixture
64def non_random_bytes_32():
65 """always use the same bytes instead of os.urandom(32)"""
66 return (
67 b'\xb9\x8aY3U_\x00j\xb4\x086K\xd9\xdb\x88N'
68 b'\xcd;\xe8$#\xfa\x12\x05\x12\x0c~\x17\x89\xa7\xf3J'
69 )
70
71
72@pytest.fixture
54def password_hash(): 73def password_hash():
55 """password hash for testing, corresponding to 'the very secret passwd'""" 74 """password hash for testing, corresponding to 'The very secret passwd'"""
56 return ( 75 return (
57 'pbkdf2_sha256$100000$kFOQkAPtStZ/Ny/O4501ygHGQnqh5Y+ySxF9qVHr' 76 'pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$h3'
58 'iv8=$3BujuWzn3CfDnw4yiD9m3F+GjeW1MHHW40R/ThHNcn0=' 77 'PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo='
59 ) 78 )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1bd9be1..884e676 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,5 @@
1# etoolkit 1# etoolkit
2# Copyright (C) 2021 Simeon Simeonov 2# Copyright (C) 2021-2022 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,12 +14,86 @@
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__"""
17import errno
18import os
19import unittest.mock
20
17import pytest 21import pytest
18 22
19import etoolkit 23import etoolkit
20from etoolkit.__main__ import main 24from etoolkit.__main__ import main
21 25
22 26
27@unittest.mock.patch('os.urandom')
28@unittest.mock.patch('builtins.input')
29def test_decrypt(binput, urandom, capsys, non_random_bytes_32, config_file):
30 """Tests encryption via the CLI interface"""
31 urandom.return_value = non_random_bytes_32
32 binput.return_value = (
33 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+'
34 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw=='
35 )
36 with unittest.mock.patch.dict(
37 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
38 ):
39 with pytest.raises(SystemExit) as exit_info:
40 main(['-c', f'{config_file}', '-d'])
41 assert exit_info.type == SystemExit
42 assert exit_info.value.code == 0
43 assert capsys.readouterr().out.strip() == 'Decrypted value: bar'
44
45
46@unittest.mock.patch('os.urandom')
47@unittest.mock.patch('builtins.input', lambda *args: 'bar')
48def test_encrypt_with_echo(urandom, capsys, non_random_bytes_32, config_file):
49 """Tests encryption via the CLI interface"""
50 urandom.return_value = non_random_bytes_32
51 with unittest.mock.patch.dict(
52 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
53 ):
54 with pytest.raises(SystemExit) as exit_info:
55 main(['-c', f'{config_file}', '-e', '-E'])
56 assert exit_info.type == SystemExit
57 assert exit_info.value.code == 0
58 assert capsys.readouterr().out.strip() == (
59 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+'
60 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw=='
61 )
62
63
64@unittest.mock.patch('os.urandom')
65@unittest.mock.patch('getpass.getpass', lambda *args: 'bar')
66def test_encrypt_without_echo(gpass, capsys, non_random_bytes_32, config_file):
67 """Tests encryption via the CLI interface"""
68 gpass.return_value = non_random_bytes_32
69 with unittest.mock.patch.dict(
70 os.environ, {'ETOOLKIT_MASTER_PASSWORD': 'the very secret passwd'}
71 ):
72 with pytest.raises(SystemExit) as exit_info:
73 main(['-c', f'{config_file}', '-e'])
74 assert exit_info.type == SystemExit
75 assert exit_info.value.code == 0
76 assert capsys.readouterr().out.strip() == (
77 'Encrypted value: enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+'
78 'hIFEgx+F4mn80o=$xdF/1S+R2MGlEQMCOLG6OjEuzw=='
79 )
80
81
82def test_list(capsys, config_file):
83 """Tests list via the CLI interface"""
84 with pytest.raises(SystemExit) as exit_info:
85 main(['-l'])
86 assert exit_info.type == SystemExit
87 assert exit_info.value.code == errno.EIO
88 with pytest.raises(SystemExit) as exit_info:
89 main(['-c', f'{config_file}', '-l'])
90 assert exit_info.type == SystemExit
91 assert exit_info.value.code == 0
92 assert capsys.readouterr().out.strip() == (
93 f'default{os.linesep}dev{os.linesep}secret'
94 )
95
96
23def test_help(capsys): 97def test_help(capsys):
24 """Dummy test checking if the CLI is available at all""" 98 """Dummy test checking if the CLI is available at all"""
25 with pytest.raises(SystemExit) as exit_info: 99 with pytest.raises(SystemExit) as exit_info:
@@ -29,6 +103,27 @@ def test_help(capsys):
29 assert capsys.readouterr().out.startswith('usage: etoolkit') 103 assert capsys.readouterr().out.startswith('usage: etoolkit')
30 104
31 105
106@unittest.mock.patch('os.urandom')
107@unittest.mock.patch('getpass.getpass')
108def test_generate_master_password_hash(
109 gpass,
110 urandom,
111 capsys,
112 non_random_bytes_32,
113):
114 """Tests master password hash generation via the CLI interface"""
115 urandom.return_value = non_random_bytes_32
116 gpass.return_value = 'The very secret passwd'
117 with pytest.raises(SystemExit) as exit_info:
118 main(['--generate-master-password-hash'])
119 assert exit_info.type == SystemExit
120 assert exit_info.value.code == 0
121 assert capsys.readouterr().out.strip() == (
122 'Master password hash: pbkdf2_sha256$100000$uYpZM1VfAGq0CDZL2duITs076'
123 'CQj+hIFEgx+F4mn80o=$h3PSPLCd37fP15zKdW4CBGn7CXE+q5UiydaF3vbeZHo='
124 )
125
126
32def test_version(capsys): 127def test_version(capsys):
33 """Dummy test checking if the CLI is available at all""" 128 """Dummy test checking if the CLI is available at all"""
34 with pytest.raises(SystemExit) as exit_info: 129 with pytest.raises(SystemExit) as exit_info:
diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py
index 2ffacd2..9061dc8 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 Simeon Simeonov 2# Copyright (C) 2021-22 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
@@ -19,7 +19,7 @@ import pytest
19import etoolkit 19import etoolkit
20 20
21 21
22def test_instantiation(config_data, password_hash): 22def test_instantiation(config_data):
23 """Tests for object instatiation""" 23 """Tests for object instatiation"""
24 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: 24 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
25 instance = etoolkit.EtoolkitInstance('devv', config_data) 25 instance = etoolkit.EtoolkitInstance('devv', config_data)
@@ -32,7 +32,9 @@ def test_instantiation(config_data, password_hash):
32 assert 'DB_CONNECTION' not in instance.sensitive_env_variables 32 assert 'DB_CONNECTION' not in instance.sensitive_env_variables
33 assert 'PASSWORD' in instance.sensitive_env_variables 33 assert 'PASSWORD' in instance.sensitive_env_variables
34 assert instance.name == 'secret' 34 assert instance.name == 'secret'
35 assert instance.master_password_hash == password_hash 35 assert instance.master_password_hash == (
36 config_data['general']['MASTER_PASSWORD_HASH']
37 )
36 assert instance.master_password is None 38 assert instance.master_password is None
37 39
38 40
diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py
index d7ac857..73a5f36 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 Simeon Simeonov 2# Copyright (C) 2021-2022 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
@@ -24,14 +24,14 @@ import etoolkit
24@unittest.mock.patch('getpass.getpass') 24@unittest.mock.patch('getpass.getpass')
25def test_confirm_password_prompt(getpass, password_hash): 25def test_confirm_password_prompt(getpass, password_hash):
26 """Tests the static EtoolkitInstance.confirm_password_prompt method""" 26 """Tests the static EtoolkitInstance.confirm_password_prompt method"""
27 getpass.return_value = 'the very secret passwd' 27 getpass.return_value = 'The very secret passwd'
28 assert ( 28 assert (
29 etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash) 29 etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash)
30 == 'the very secret passwd' 30 == 'The very secret passwd'
31 ) 31 )
32 assert ( 32 assert (
33 etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash, False) 33 etoolkit.EtoolkitInstance.confirm_password_prompt(password_hash, False)
34 == 'the very secret passwd' 34 == 'The very secret passwd'
35 ) 35 )
36 36
37 37
@@ -39,10 +39,10 @@ def test_decrypt():
39 """Tests the static EtoolkitInstance.decrypt method""" 39 """Tests the static EtoolkitInstance.decrypt method"""
40 assert ( 40 assert (
41 etoolkit.EtoolkitInstance.decrypt( 41 etoolkit.EtoolkitInstance.decrypt(
42 'the very secret passwd', 42 'The very secret passwd',
43 ( 43 (
44 'enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9' 44 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx'
45 'o+ABvsxogIXpJim16Gz5SVtV8=' 45 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY='
46 ), 46 ),
47 ) 47 )
48 == 'secret1' 48 == 'secret1'
@@ -51,11 +51,11 @@ def test_decrypt():
51 # now test with modified edata 51 # now test with modified edata
52 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: 52 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
53 edata = ( 53 edata = (
54 'enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9' 54 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ5ZoAkurNgMYx'
55 'o+ABvsxogIXpJim17Gz5SVtV8=' 55 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY='
56 ) 56 )
57 etoolkit.EtoolkitInstance.decrypt( 57 etoolkit.EtoolkitInstance.decrypt(
58 'the very secret passwd', edata 58 'The very secret passwd', edata
59 ) == 'secret1' 59 ) == 'secret1'
60 assert exc_info.type is etoolkit.EtoolkitInstanceError 60 assert exc_info.type is etoolkit.EtoolkitInstanceError
61 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' 61 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
@@ -70,17 +70,32 @@ def test_encrypt():
70 assert edata != etoolkit.EtoolkitInstance.encrypt('foo', 'bar') 70 assert edata != etoolkit.EtoolkitInstance.encrypt('foo', 'bar')
71 71
72 72
73@unittest.mock.patch('os.urandom')
74def test_encrypt_staticly(urandom, non_random_bytes_32):
75 """Tests the EtoolkitInstance.encrypt method always with the same salt"""
76 urandom.return_value = non_random_bytes_32
77 edata = etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'bar')
78 assert edata == (
79 'enc-val$1$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80'
80 'o=$HjPFNv6xC5hbMrFc0L5lSkWdfQ=='
81 )
82 assert edata == etoolkit.EtoolkitInstance.encrypt(
83 'The very secret passwd',
84 'bar',
85 )
86
87
73def test_get_new_password_hash(): 88def test_get_new_password_hash():
74 """Tests the static EtoolkitInstance.get_new_password_hash method""" 89 """Tests the static EtoolkitInstance.get_new_password_hash method"""
75 new_hash = etoolkit.EtoolkitInstance.get_new_password_hash( 90 new_hash = etoolkit.EtoolkitInstance.get_new_password_hash(
76 'the very secret passwd' 91 'The very secret passwd'
77 ) 92 )
78 # all pbkdf2 params are the same / hardcoded for the time being 93 # all pbkdf2 params are the same / hardcoded for the time being
79 assert new_hash.startswith('pbkdf2_sha256$100000$') 94 assert new_hash.startswith('pbkdf2_sha256$100000$')
80 assert len(new_hash) == 110 95 assert len(new_hash) == 110
81 # the hash should always be different because of random salting 96 # the hash should always be different because of random salting
82 assert new_hash != etoolkit.EtoolkitInstance.get_new_password_hash( 97 assert new_hash != etoolkit.EtoolkitInstance.get_new_password_hash(
83 'the very secret passwd' 98 'The very secret passwd'
84 ) 99 )
85 100
86 101
@@ -95,8 +110,8 @@ def test_parse_value():
95def test_password_matches(password_hash): 110def test_password_matches(password_hash):
96 """Tests the static EtoolkitInstance.password_matches method""" 111 """Tests the static EtoolkitInstance.password_matches method"""
97 assert etoolkit.EtoolkitInstance.password_matches( 112 assert etoolkit.EtoolkitInstance.password_matches(
98 'the very secret passwd', password_hash 113 'The very secret passwd', password_hash
99 ) 114 )
100 assert not etoolkit.EtoolkitInstance.password_matches( 115 assert not etoolkit.EtoolkitInstance.password_matches(
101 'the very secret passwdo', password_hash 116 'The very secret passwdo', password_hash
102 ) 117 )