diff options
| author | Simeon Simeonov | 2022-03-04 20:48:20 +0100 |
|---|---|---|
| committer | Simeon Simeonov | 2022-03-04 20:48:20 +0100 |
| commit | 907f80e1889781a69edded00126b3e78ddeb7b8e (patch) | |
| tree | 20be15e81762d7a72e4caa40bda8b4301339efe4 | |
| parent | 4914b0c74ed669d6631897950ff1660ce91ef918 (diff) | |
Add the -P param, bash completion script and improve the README.md
| -rw-r--r-- | README.md | 33 | ||||
| -rw-r--r-- | completion/etoolkit.bash | 105 | ||||
| -rw-r--r-- | etoolkit/__init__.py | 4 | ||||
| -rw-r--r-- | etoolkit/__main__.py | 79 | ||||
| -rwxr-xr-x | etoolkit/etoolkit.py | 6 | ||||
| -rw-r--r-- | tests/conftest.py | 31 | ||||
| -rw-r--r-- | tests/test_cli.py | 97 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance.py | 8 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance_static.py | 43 |
9 files changed, 363 insertions, 43 deletions
| @@ -117,7 +117,7 @@ file are encrypted with the same master password. Setting a master password | |||
| 117 | hash is a recommended but not mandatory. | 117 | hash 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 | ||
| 123 | That command will prompt for master password and output a hash that can then | 123 | That 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 | |||
| 127 | provided at a later time. Issuing: | 127 | provided at a later time. Issuing: |
| 128 | 128 | ||
| 129 | ```bash | 129 | ```bash |
| 130 | etoolkit -e | 130 | etoolkit --encrypt-value |
| 131 | ``` | 131 | ``` |
| 132 | 132 | ||
| 133 | will prompt for the master password, then for the value to be encrypted and | 133 | will 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 | |||
| 140 | of single value(s): | 140 | of single value(s): |
| 141 | 141 | ||
| 142 | ```bash | 142 | ```bash |
| 143 | etoolkit -d -m | 143 | etoolkit --decrypt-value --multiple-values |
| 144 | ``` | 144 | ``` |
| 145 | 145 | ||
| 146 | Another 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 | ||
| 154 | will be used instead of prompting for password. | ||
| 155 | |||
| 146 | Listing all available instances defined in the configuration file and then | 156 | Listing all available instances defined in the configuration file and then |
| 147 | loading a specific instance can be achieved by: | 157 | loading 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 | ||
| 184 | Contact the author for questions and suggestions! :) | 194 | Contact 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 | ||
| 281 | A quick and dirty bash completion for available instances can be set at the | ||
| 282 | bottom of your bash startup file: | ||
| 283 | |||
| 284 | ```bash | ||
| 285 | complete -W '$(compgen -W "$(etoolkit -l)")' etoolkit | ||
| 286 | ``` | ||
| 287 | |||
| 288 | A 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 | ||
| 284 | Copyright (c) 2021, Simeon Simeonov | 303 | Copyright (C) 2021-2022 Simeon Simeonov |
| 285 | All rights reserved. | 304 | All 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 | |||
| 105 | complete -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 @@ | |||
| 17 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | 17 | from .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 | |||
| 24 | import argparse | 24 | import argparse |
| 25 | import errno | 25 | import errno |
| 26 | import getpass | 26 | import getpass |
| 27 | import io | ||
| 27 | import json | 28 | import json |
| 28 | import logging | 29 | import logging |
| 29 | import os | 30 | import 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 | ||
| 74 | def encrypt_value(args: argparse.Namespace, config: dict): | 94 | def 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 | ||
| 109 | def main(inargs=None): | 150 | def 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""" |
| 17 | import json | ||
| 18 | |||
| 17 | import pytest | 19 | import 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 |
| 56 | def 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 | ||
| 64 | def 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 | ||
| 54 | def password_hash(): | 73 | def 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__""" |
| 17 | import errno | ||
| 18 | import os | ||
| 19 | import unittest.mock | ||
| 20 | |||
| 17 | import pytest | 21 | import pytest |
| 18 | 22 | ||
| 19 | import etoolkit | 23 | import etoolkit |
| 20 | from etoolkit.__main__ import main | 24 | from etoolkit.__main__ import main |
| 21 | 25 | ||
| 22 | 26 | ||
| 27 | @unittest.mock.patch('os.urandom') | ||
| 28 | @unittest.mock.patch('builtins.input') | ||
| 29 | def 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') | ||
| 48 | def 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') | ||
| 66 | def 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 | |||
| 82 | def 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 | |||
| 23 | def test_help(capsys): | 97 | def 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') | ||
| 108 | def 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 | |||
| 32 | def test_version(capsys): | 127 | def 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 | |||
| 19 | import etoolkit | 19 | import etoolkit |
| 20 | 20 | ||
| 21 | 21 | ||
| 22 | def test_instantiation(config_data, password_hash): | 22 | def 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') |
| 25 | def test_confirm_password_prompt(getpass, password_hash): | 25 | def 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') | ||
| 74 | def 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 | |||
| 73 | def test_get_new_password_hash(): | 88 | def 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(): | |||
| 95 | def test_password_matches(password_hash): | 110 | def 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 | ) |
