summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ruff.toml73
-rw-r--r--CHANGELOG.md15
-rw-r--r--README.md133
-rw-r--r--completion/etoolkit.bash7
-rw-r--r--etoolkit_sample.json16
-rw-r--r--src/etoolkit/__init__.py2
-rw-r--r--src/etoolkit/__main__.py371
-rw-r--r--src/etoolkit/etoolkit.py110
-rw-r--r--tests/conftest.py61
-rw-r--r--tests/test_cli.py57
-rw-r--r--tests/test_envtoolkit_instance.py6
-rw-r--r--tests/test_envtoolkit_instance_static.py136
12 files changed, 725 insertions, 262 deletions
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..add2491
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,73 @@
1cache-dir = "~/.cache/ruff"
2indent-width = 4
3line-length = 79
4target-version = "py312"
5
6[lint]
7select = ["ALL"]
8ignore = ["ANN", "COM812", "D105", "D202", "D203", "D205", "D211", "D212", "D400", "D401", "D403", "D415", "ERA001", "FBT001", "FBT002", "PTH111", "RUF012", "RUF013", "S101", "TRY300", "BLE001", "UP020", "C901", "D200", "D402", "EM101", "EM102", "FBT003", "INP001", "PLR0912", "PLR0913", "PLR0915", "PLR2004", "PLW2901", "S603", "T201", "TRY003", "TRY400"]
9# D105 - Missing docstring in magic method
10# D200 - One-line docstring should fit on one line
11# D203 - 1 blank line required before class docstring
12# D205 - 1 blank line required between summary line and description
13# D403 - First word of the first line should be capitalized: `str` -> `Str`
14# FBT001 - Boolean-typed positional argument in function definition
15# FBT002 - Boolean default positional argument in function definition
16# PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()`
17# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar`
18# RUF013 - PEP 484 prohibits implicit `Optional`
19# S101 - Use of `assert` detected
20# TRY300 - Consider moving this statement to an `else` block
21# TRY400 - Use `logging.exception` instead of `logging.error`
22# UP020 - Use builtin `open`
23
24# Project specific
25# C901 - `X` is too complex
26# D200 - One-line docstring should fit on one line
27# D402 - First line should not be the function's signature (bug in ruff 0.4.4)
28# EM101 - Exception must not use a string literal, assign to variable first
29# EM102 - Exception must not use an f-string literal, assign to variable first
30# FBT003 - Boolean positional value in function call
31# INP001 - File `tests/test_envtoolkit_instance_static.py` is part of an implicit namespace package. Add an `__init__.py`.
32# PLR0912 - Too many branches
33# PLR0913 - Too many arguments in function definition
34# PLR0915 - Too many statements
35# PLR2004 - Magic value used in comparison, consider replacing `X` with a constant variable
36# PLW2901 - `for` loop variable `value` overwritten by assignment target
37# S603 - `subprocess` call: check for execution of untrusted input
38# T201 - `print` found
39# TRY003 - Avoid specifying long messages outside the exception class
40
41# Allow fix for all enabled rules (when `--fix`) is provided.
42fixable = ["ALL"]
43unfixable = []
44
45[format]
46# Like Black, use double quotes for strings.
47quote-style = "single"
48
49# Like Black, indent with spaces, rather than tabs.
50indent-style = "space"
51
52# Like Black, respect magic trailing commas.
53skip-magic-trailing-comma = true
54
55# Like Black, automatically detect the appropriate line ending.
56line-ending = "auto"
57
58# Enable auto-formatting of code examples in docstrings. Markdown,
59# reStructuredText code/literal blocks and doctests are all supported.
60#
61# This is currently disabled by default, but it is planned for this
62# to be opt-out in the future.
63docstring-code-format = false
64
65# Set the line length limit used when formatting code snippets in
66# docstrings.
67#
68# This only has an effect when the `docstring-code-format` setting is
69# enabled.
70docstring-code-line-length = "dynamic"
71
72[lint.flake8-quotes]
73inline-quotes = "single"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d48f9a9..aca6068 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
1# Changelog 1# Changelog
2 2
3## [2.0.0](https://github.com/blackm0re/etoolkit/tree/2.0.0) (2024-05-13)
4
5[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.2.0...2.0.0)
6
7**Changes:**
8
9- etoolkit encryption format v2, adding rnd. padding for values < 32 bytes
10
11- re-encryption support
12
13- replaced *os.system* with *subprocess*
14
15- new etoolkit.EtoolkitInstance API (not compatible with v1)
16
17
3## [1.2.0](https://github.com/blackm0re/etoolkit/tree/1.2.0) (2022-04-04) 18## [1.2.0](https://github.com/blackm0re/etoolkit/tree/1.2.0) (2022-04-04)
4 19
5[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.1.0...1.2.0) 20[Full Changelog](https://github.com/blackm0re/etoolkit/compare/1.1.0...1.2.0)
diff --git a/README.md b/README.md
index 6c58bdd..45c230a 100644
--- a/README.md
+++ b/README.md
@@ -69,12 +69,97 @@ for processes that were not spawned by that same *etoolkit* session.
69 # add sgs' custom repository using app-eselect/eselect-repository 69 # add sgs' custom repository using app-eselect/eselect-repository
70 eselect repository add sgs 70 eselect repository add sgs
71 71
72 # ... or using layman (obsolete)
73 layman -a sgs
74
75 emerge dev-python/etoolkit 72 emerge dev-python/etoolkit
76 ``` 73 ```
77 74
75## Encryption & decryption scheme
76
77The etoolkit encryption format is currently at version 2.
78Encrypted values start with *enc-val$2$*.
79
80This new version introduces padding for values that are shorter than 32 bytes.
81The idea behind padding is to generate (32 - value length) random bytes and
82append them to the original value.
83That prevents a potential attacker from knowing the length of the encrypted
84short value (f.i. password, PIN number, username... etc).
85
86Values encrypted in the old format (*enc-val$1$*) can still be decrypted
87seamlessly.
88
89Authenticated encryption with associated data (AEAD) is implemented using
90AES-GCM.
91
92
93### Encryption
94
95Input:
96
97- plain-text value to be encrypted (P)
98
99- plain-text master-password used for key derivation (M)
100
101
102Output:
103
104- an encrypted value digest (base64) (B)
105
106
107Operation:
108
109- generate 32 bytes of random data to be used as a salt (S)
110
111- derive a 32 bytes key (K): K = scrypt(M, S, n=2**14, r=8, p=1)
112
113- use the first 12 bytes of S as nonce (NONCE)
114
115- calculate the padding length (L) as 32 - length of P, if P < 32, 0 otherwise
116
117- set the padding length bytes (N) (2bytes) to "%02d", if L > 0, "-1" otherwise
118
119- generate L bytes of random data to be used for padding (D)
120
121- encrypt and auth. P, auth.only S (E): E = AES_GCM_ENC(K, NONCE, N + P + D, S)
122
123- encrypted value digest (B) = enc-val$2$:BASE64_ENCODE(S)$BASE64_ENCODE(E)
124
125example:
126enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hFSujpe35X/XpzBegJJpo86AiCsNsUS6B6JM=
127
128
129### Decryption
130
131Input:
132
133- encrypted value digest (base64) (B)
134
135- plain-text master-password used for key derivation (M)
136
137
138Output:
139
140- plain-text password (P)
141
142
143Operation:
144
145- remove the prefix (enc-val$2$) from B and split the remaining value by '$'
146
147- base64-decode the salt (S): S = BASE64_DECODE(B1)
148
149- base64-decode the rest of the data (E): E = BASE64_DECODE(B2)
150
151- derive a 32 bytes key (K): K = scrypt(M, S, n=2**14, r=8, p=1)
152
153- use the first 12 bytes of S as nonce (NONCE)
154
155- decrypt the encrypted data (D): D = AES_GCM_DECRYPT(K, NONCE, E, S)
156
157- fetch the first 2 bytes (padding length bytes) (N): N = D[0 : 2]
158
159- calculate the padding length (L): L = INT(N) if N != "-1", 0 otherwise
160
161- fetch the plain-text (P): P = D[2 : -L] if L != 0, D[2 :] otherwise
162
78 163
79## Setup and examples 164## Setup and examples
80 165
@@ -201,6 +286,17 @@ One can also spawn a different process than an interactive shell by using the
201 etoolkit --spawn /bin/othershell <instance-name> 286 etoolkit --spawn /bin/othershell <instance-name>
202 ``` 287 ```
203 288
289It is possible to re-encrypt all encrypted values in a specific instance or in
290all defined instances either by using the same or a new master password.
291
292 ```bash
293 etoolkit --reencrypt all
294 ```
295
296will prompt for the current master password, then for a new master password
297(with confirmation) and finally the new config file (if "all") or instance
298contents will be displayed.
299
204Contact the author for questions and suggestions! :) 300Contact the author for questions and suggestions! :)
205 301
206 302
@@ -223,11 +319,11 @@ or the *instances* structure being loaded from a diferent configuration file
223 319
224 320
225 # using some static methods in order to create encrypted values 321 # using some static methods in order to create encrypted values
226 etoolkit.EtoolkitInstance.encrypt('the very secret passwd', 'secret1') 322 etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'secret1')
227 # Out: 'enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9o+ABvsxogIXpJim16Gz5SVtV8=' 323 # Out: 'enc-val$2$NDdp6WMbX7gdEyzGM5nI4jhyer4XL+BoQwAHtL2CXHw=$+Pztn1pfaXKjPpem5PIQrCNxR9pyE6zqgSoGg9qXvmhH6VsNQvUTmiaOvUFl35EbiYE='
228 324
229 etoolkit.EtoolkitInstance.encrypt('the very secret passwd', 'secret2') 325 etoolkit.EtoolkitInstance.encrypt('The very secret passwd', 'secret2')
230 # Out: 'enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=' 326 # Out: 'enc-val$2$H953GxW+qrYXIp+I97lJBmG1gv89wxcfmTu7PEpZzjE=$Tb3F8/izDbHAMklpIjYk73JAiav+w8ZhrMsO93FlQjGh4MTChjp2Yen5BxSBOWLvCD4='
231 327
232 328
233 # The encrypted values will be used in our configuration structure 329 # The encrypted values will be used in our configuration structure
@@ -237,34 +333,33 @@ or the *instances* structure being loaded from a diferent configuration file
237 }, 333 },
238 "instances": { 334 "instances": {
239 "_default": { 335 "_default": {
240 "ETOOLKIT_PROMPT": "(%i)", 336 "ETOOLKIT_PROMPT": "(%i)",
241 "PYTHONPATH": "/home/user/%i/python", 337 "ETOOLKIT_SENSITIVE": ["DB_CONNECTION", "ETOOLKIT_TEST_PASSWORD"]
242 }, 338 },
243 "dev": { 339 "dev": {
244 "ETOOLKIT_PARENT": "_default", 340 "ETOOLKIT_PARENT": "_default",
245 "PYTHONPATH": "%p:/home/user/%i/.pythonpath", 341 "PYTHONPATH": ":/home/user/.pythonpath",
342 "DB_CONNECTION": "enc-val$2$RAgDei59tUvDAkrBmxROqRaV/NxNFEI2eJIOP7sG/b8=$yse7zawHCzQCU31sZj4oJYLGonz1M7oqHqCilXLHkywa9nMPALypmVzi3QekekYuLeb5XVTmmp84NHoPn1M052otoRHSp+TMPsqBPRabfriIKEK4XQ=="
246 }, 343 },
247 "secret": { 344 "secret": {
248 "ETOOLKIT_PARENT": "_default", 345 "ETOOLKIT_PARENT": "_default",
249 "ETOOLKIT_SENSITIVE": ["PASSWORD"],
250 "GNUPGHOME": "%h/private/.gnupg", 346 "GNUPGHOME": "%h/private/.gnupg",
251 "PASSWORD": "enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=" 347 "ETOOLKIT_TEST_PASSWORD": "enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U="
252 } 348 }
253 } 349 }
254 } 350 }
255 351
256 352 secret_instance = etoolkit.EtoolkitInstance('secret', instances)
257 dev_instance = etoolkit.EtoolkitInstance('dev', instances)
258 353
259 # fetch the variables before the processing stage (calling get_environ()) 354 # fetch the variables before the processing stage (calling get_environ())
260 # since raw_env_variables is a dict, it can be modified (f.i. .update()) 355 # since raw_env_variables is a dict, it can be modified (f.i. .update())
261 dev_instance.raw_env_variables 356 secret_instance.raw_env_variables
262 357
263 dev_instance.master_password = 'the very secret passwd' # or perhaps using getpass 358 secret_instance.master_password = 'The very secret passwd' # or perhaps using getpass
264 env_vars = dev_instance.get_env() 359 env_vars = secret_instance.get_environ()
265 print(env_vars['PASSWORD']) # outputs: 'secret2' 360 print(env_vars['ETOOLKIT_TEST_PASSWORD']) # outputs: 'secret1'
266 361
267 inst.dump_env(env_vars) # prints all values, with the exception of 'PASSWORD' 362 secret_instance.env_to_str(env_vars) # prints all values, with the exception of 'ETOOLKIT_TEST_PASSWORD'
268 363
269 # set the env. variables. 364 # set the env. variables.
270 os.environ.update(env_vars) 365 os.environ.update(env_vars)
diff --git a/completion/etoolkit.bash b/completion/etoolkit.bash
index 2e80811..8faefc2 100644
--- a/completion/etoolkit.bash
+++ b/completion/etoolkit.bash
@@ -30,7 +30,7 @@ _etoolkit() {
30 all_params="-d --decrypt-value -e --encrypt-value -l --list -h --help 30 all_params="-d --decrypt-value -e --encrypt-value -l --list -h --help
31 -P --master-password-prompt -p --generate-master-password-hash 31 -P --master-password-prompt -p --generate-master-password-hash
32 -c --config-file -E --echo -m --multiple-values -q --no-output 32 -c --config-file -E --echo -m --multiple-values -q --no-output
33 -s --spawn -v --version" 33 -r --reencrypt -s --spawn -v --version"
34 # if [ ${prev:0:1} == "-" ] 34 # if [ ${prev:0:1} == "-" ]
35 35
36 if [ ${COMP_CWORD} -eq 1 ]; then 36 if [ ${COMP_CWORD} -eq 1 ]; then
@@ -78,6 +78,11 @@ _etoolkit() {
78 COMPREPLY=($(compgen -W "-d --decrypt-value -E -e --echo --encrypt-value -m --multiple-values" -- "$cur")) 78 COMPREPLY=($(compgen -W "-d --decrypt-value -E -e --echo --encrypt-value -m --multiple-values" -- "$cur"))
79 return 79 return
80 ;; 80 ;;
81 "-r" | "--reencrypt")
82 COMPREPLY=($(compgen -W "all" -- "$cur"))
83 _instances "$cur"
84 return
85 ;;
81 "-s" | "--spawn") 86 "-s" | "--spawn")
82 COMPREPLY=($(compgen -c -- "$cur")) 87 COMPREPLY=($(compgen -c -- "$cur"))
83 return 88 return
diff --git a/etoolkit_sample.json b/etoolkit_sample.json
index 66c9d17..af3ed75 100644
--- a/etoolkit_sample.json
+++ b/etoolkit_sample.json
@@ -1,21 +1,21 @@
1{ 1{
2 "general": { 2 "general": {
3 "MASTER_PASSWORD_HASH": "pbkdf2_sha256$100000$kFOQkAPtStZ/Ny/O4501ygHGQnqh5Y+ySxF9qVHriv8=$3BujuWzn3CfDnw4yiD9m3F+GjeW1MHHW40R/ThHNcn0=" 3 "MASTER_PASSWORD_HASH": "pbkdf2_sha256$500000$UY3o78KUM1Btzxk3k3JCsijnwtJ2lx+hH9NewpVKxo8=$tHwDm8OVKanC4DoYTigTCb0R3lQIa/CbBYj0B3TZtHg="
4 }, 4 },
5 "instances": { 5 "instances": {
6 "default": { 6 "_default": {
7 "ETOOLKIT_PROMPT": "(%i)" 7 "ETOOLKIT_PROMPT": "(%i)",
8 "ETOOLKIT_SENSITIVE": ["DB_CONNECTION", "ETOOLKIT_TEST_PASSWORD"]
8 }, 9 },
9 "dev": { 10 "dev": {
10 "ETOOLKIT_PARENT": "default", 11 "ETOOLKIT_PARENT": "_default",
11 "PYTHONPATH": ":/home/user/.pythonpath", 12 "PYTHONPATH": ":/home/user/.pythonpath",
12 "DB_CONNECTION": "enc-val$1$Y/TBb1F3siHTw6qZg9ERzZfA8PLPf2CwGSQLpu9jYWw=$FT5tS9o+ABvsxogIXpJim16Gz5SVtV8=" 13 "DB_CONNECTION": "enc-val$2$RAgDei59tUvDAkrBmxROqRaV/NxNFEI2eJIOP7sG/b8=$yse7zawHCzQCU31sZj4oJYLGonz1M7oqHqCilXLHkywa9nMPALypmVzi3QekekYuLeb5XVTmmp84NHoPn1M052otoRHSp+TMPsqBPRabfriIKEK4XQ=="
13 }, 14 },
14 "secret": { 15 "secret": {
15 "ETOOLKIT_PARENT": "default", 16 "ETOOLKIT_PARENT": "_default",
16 "ETOOLKIT_SENSITIVE": ["PASSWORD"],
17 "GNUPGHOME": "%h/private/.gnupg", 17 "GNUPGHOME": "%h/private/.gnupg",
18 "PASSWORD": "enc-val$1$vIBcoCNiYrsDLtF41uLuSEnppBjhliD0B8jwcBJcj/c=$KwOGe/y1dlxktDaCnJPIVNuaQ4Q7yNo=" 18 "ETOOLKIT_TEST_PASSWORD": "enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U="
19 } 19 }
20 } 20 }
21} 21}
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py
index 0ef5957..711c6ae 100644
--- a/src/etoolkit/__init__.py
+++ b/src/etoolkit/__init__.py
@@ -18,7 +18,7 @@
18from .etoolkit import EtoolkitInstance, EtoolkitInstanceError 18from .etoolkit import EtoolkitInstance, EtoolkitInstanceError
19 19
20__author__ = 'Simeon Simeonov' 20__author__ = 'Simeon Simeonov'
21__version__ = '1.3.0' 21__version__ = '2.0.0'
22__license__ = 'GPL3' 22__license__ = 'GPL3'
23 23
24 24
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py
index 8b5bd55..a2f2f20 100644
--- a/src/etoolkit/__main__.py
+++ b/src/etoolkit/__main__.py
@@ -42,117 +42,244 @@ DEFAULT_LOG_LEVEL = logging.WARNING
42logger = logging.getLogger(__name__) 42logger = logging.getLogger(__name__)
43 43
44 44
45def decrypt_value(args: argparse.Namespace, config: dict): 45class EtoolkitCLIHandler:
46 """ 46 """
47 Interactive function for decrypting value(s) 47 Helper class used for handleing the growing amount of arguments
48 48
49 Prompts for master key password and then prompts for a value to decrypt 49 This class consists mostly of interactive methods and is not intended as
50 a part of the etoolkit API
51 """
50 52
51 The decrypted value is printed to stdout 53 def __init__(self, args: argparse.Namespace, config_dict: dict):
54 """
55 :param args: The parsed argparse arguments sent by the caller
56 :type args: argparse.Namespace
52 57
53 :param args: The arguments sent by the caller 58 :param config_dict: The config file structure
54 :type args: arparse.Namespace 59 :type config_dict: dict
60 """
61 self._args = args
62 self._config_dict = config_dict
55 63
56 :param config: The config dict sent by the caller 64 self._password_hash = None
57 :type config: dict 65 if 'general' in config_dict:
58 """ 66 self._password_hash = config_dict['general'].get(
59 password_hash = None 67 'MASTER_PASSWORD_HASH'
60 pipe_input = None 68 )
61 if not os.isatty(sys.stdin.fileno()):
62 pipe_input = sys.stdin.read().strip()
63 if 'general' in config:
64 password_hash = config['general'].get('MASTER_PASSWORD_HASH')
65 69
66 if ( 70 self._password_from_env = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
67 args.master_password_prompt
68 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
69 ):
70 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
71 password_hash, False
72 )
73 else:
74 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
75 71
76 if pipe_input: 72 def decrypt_value(self):
77 # the input came from stdin. No need to prompt 73 """
78 print( 74 Interactive method for decrypting value(s)
79 'Decrypted value: ' 75
80 f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}' 76 Prompts for master key password and then prompts for a value to decrypt
81 ) 77
82 return 78 The decrypted value is printed to stdout
83 while True: 79 """
84 try: 80 pipe_input = None
85 value = input('Value: ') 81 if not os.isatty(sys.stdin.fileno()):
82 pipe_input = sys.stdin.read().strip()
83
84 if (
85 self._args.master_password_prompt
86 or self._password_from_env is None
87 ):
88 password = self._password_prompt()
89 else:
90 password = self._password_from_env
91
92 if pipe_input:
93 # the input came from stdin. No need to prompt
86 print( 94 print(
87 'Decrypted value: ' 95 'Decrypted value: '
88 f'{etoolkit.EtoolkitInstance.decrypt(password, value)}' 96 f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}'
89 ) 97 )
90 if not args.multiple_values: 98 return
99 while True:
100 try:
101 value = input('Value: ')
102 print(
103 'Decrypted value: '
104 f'{etoolkit.EtoolkitInstance.decrypt(password, value)}'
105 )
106 if not self._args.multiple_values:
107 break
108 except KeyboardInterrupt:
109 print(os.linesep)
91 break 110 break
92 except KeyboardInterrupt: 111 return
93 print(os.linesep)
94 break
95 return
96 112
113 def encrypt_value(self):
114 """
115 Interactive method for encrypting value(s)
97 116
98def encrypt_value(args: argparse.Namespace, config: dict): 117 Prompts for master key password and then prompts for a value to encrypt
99 """
100 Interactive function for encrypting value(s)
101 118
102 Prompts for master key password and then prompts for a value to encrypt 119 The encrypted value is printed to stdout
120 """
121 pipe_input = None
122 if not os.isatty(sys.stdin.fileno()):
123 pipe_input = sys.stdin.read().strip()
103 124
104 The encrypted value is printed to stdout 125 if (
126 self._args.master_password_prompt
127 or self._password_from_env is None
128 ):
129 password = self._password_prompt_confirm()
130 else:
131 password = self._password_from_env
105 132
106 :param args: The arguments sent by the caller 133 if pipe_input:
107 :type args: arparse.Namespace 134 # the input came from stdin. No need to prompt
135 print(
136 'Encrypted value: '
137 f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}'
138 )
139 return
108 140
109 :param config: The config dict sent by the caller 141 while True:
110 :type config: dict 142 try:
111 """ 143 if self._args.echo:
112 password_hash = None 144 value = input('Value: ')
113 pipe_input = None 145 else:
114 if not os.isatty(sys.stdin.fileno()): 146 value = getpass.getpass('Value: ')
115 pipe_input = sys.stdin.read().strip() 147 print(
116 if 'general' in config: 148 'Encrypted value: '
117 password_hash = config['general'].get('MASTER_PASSWORD_HASH') 149 f'{etoolkit.EtoolkitInstance.encrypt(password, value)}'
150 )
151 if not self._args.multiple_values:
152 break
153 except KeyboardInterrupt:
154 print(os.linesep)
155 break
156 return
157
158 def generate_master_password_hash(self):
159 """
160 Interactive method for generating password hash
161
162 Prompts for master key password and then for confirmation
118 163
119 if ( 164 The generated hash is printed to stdout
120 args.master_password_prompt 165 """
121 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None 166 phash = etoolkit.EtoolkitInstance.get_new_password_hash(
122 ): 167 etoolkit.EtoolkitInstance.confirm_password_prompt()
123 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
124 password_hash
125 ) 168 )
126 else: 169 print(f'Master password hash: {phash}')
127 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
128 170
129 if pipe_input: 171 def list(self):
130 # the input came from stdin. No need to prompt 172 """Lists all instances defined in the config file"""
131 print( 173
132 'Encrypted value: ' 174 for instance_name in sorted(
133 f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}' 175 filter(
176 lambda s: not s.startswith('_'),
177 self._config_dict.get('instances', {}).keys(),
178 )
179 ):
180 print(instance_name)
181
182 def load_instance(self):
183 """Loads a single specified instance from the config file"""
184
185 inst = etoolkit.EtoolkitInstance(
186 self._args.instance, self._config_dict
134 ) 187 )
135 return 188
136 while True: 189 if (
137 try: 190 self._args.master_password_prompt
138 if args.echo: 191 or self._password_from_env is None
139 value = input('Value: ') 192 ):
140 else: 193 inst.prompt_func = (
141 value = getpass.getpass('Value: ') 194 etoolkit.EtoolkitInstance.confirm_password_prompt
195 )
196
197 env = inst.get_environ()
198
199 if self._args.dump_output:
200 print(inst.env_to_str(env))
201
202 os.environ.update(env)
203
204 if self._args.spawn:
205 subprocess.run(self._args.spawn.split(), check=False)
206 else:
207 subprocess.run(
208 os.environ.get('SHELL', 'bash').split(), check=False
209 )
210
211 def reencrypt(self):
212 """
213 Interactive method that prints new configuration data (JSON) to stdout
214
215 Prompts for master key password and then for a new password,
216 which may be the same as the current password
217
218 All existing encrypted values are decrypted using the current password
219 and then encrypted with the new password
220 """
221 print('(Current password) ', end='', flush=True)
222 if (
223 self._args.master_password_prompt
224 or self._password_from_env is None
225 ):
226 password = self._password_prompt()
227 else:
228 password = self._password_from_env
229
230 print('(New password) ', end='', flush=True)
231 new_password = etoolkit.EtoolkitInstance.confirm_password_prompt()
232
233 if self._args.reencrypt != 'all':
234 # re-encrypt a single instance
235 inst = etoolkit.EtoolkitInstance(
236 self._args.reencrypt, self._config_dict
237 )
142 print( 238 print(
143 'Encrypted value: ' 239 json.dumps(
144 f'{etoolkit.EtoolkitInstance.encrypt(password, value)}' 240 inst.get_reencrypted_instance_data(new_password, password),
241 indent=4,
242 )
145 ) 243 )
146 if not args.multiple_values: 244 return
147 break 245
148 except KeyboardInterrupt: 246 # re-encrypt all
149 print(os.linesep) 247 new_config_dict = dict(self._config_dict)
150 break 248 if (
151 return 249 'general' in new_config_dict
250 and 'MASTER_PASSWORD_HASH' in new_config_dict['general']
251 ):
252 new_config_dict['general']['MASTER_PASSWORD_HASH'] = (
253 etoolkit.EtoolkitInstance.get_new_password_hash(new_password)
254 )
255
256 for instance_name in self._config_dict['instances']:
257 inst = etoolkit.EtoolkitInstance(instance_name, self._config_dict)
258 new_config_dict['instances'][instance_name] = (
259 inst.get_reencrypted_instance_data(new_password, password)
260 )
261 print(json.dumps(new_config_dict, indent=4))
262
263 def _password_prompt(self) -> str:
264 """
265 Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=False)
266 """
267 return etoolkit.EtoolkitInstance.confirm_password_prompt(
268 self._password_hash, False
269 )
270
271 def _password_prompt_confirm(self) -> str:
272 """
273 Wrapper for EtoolkitInstance.confirm_password_prompt(confirm=True)
274 """
275 return etoolkit.EtoolkitInstance.confirm_password_prompt(
276 self._password_hash
277 )
152 278
153 279
154def main(inargs=None): 280def main(inargs=None):
155 """main entry point""" 281 """main entry point"""
282
156 parser = argparse.ArgumentParser( 283 parser = argparse.ArgumentParser(
157 prog=__package__, 284 prog=__package__,
158 epilog=( 285 epilog=(
@@ -207,6 +334,20 @@ def main(inargs=None):
207 required=False, 334 required=False,
208 help='Prompt for master password, display the generated hash and exit', 335 help='Prompt for master password, display the generated hash and exit',
209 ) 336 )
337 group.add_argument(
338 '-r',
339 '--reencrypt',
340 metavar='<instance | all>',
341 type=str,
342 default='',
343 dest='reencrypt',
344 required=False,
345 help=(
346 'Prompt for current master password, new master password and '
347 're-encrypt either all encrypted values or only those for a '
348 'given instance'
349 ),
350 )
210 parser.add_argument( 351 parser.add_argument(
211 '-c', 352 '-c',
212 '--config-file', 353 '--config-file',
@@ -274,7 +415,7 @@ def main(inargs=None):
274 try: 415 try:
275 with io.open(args.config_file, encoding='utf-8') as fp: 416 with io.open(args.config_file, encoding='utf-8') as fp:
276 config_dict = json.load(fp) 417 config_dict = json.load(fp)
277 except FileNotFoundError as e: 418 except FileNotFoundError as err:
278 # do not raise exception if config-file is missing for: 419 # do not raise exception if config-file is missing for:
279 # - decrypting value 420 # - decrypting value
280 # - encrypting value 421 # - encrypting value
@@ -288,66 +429,38 @@ def main(inargs=None):
288 config_dict = {} 429 config_dict = {}
289 else: 430 else:
290 logger.error('Configuration file %s is missing', args.config_file) 431 logger.error('Configuration file %s is missing', args.config_file)
291 raise SystemExit(errno.EIO) from e 432 raise SystemExit(errno.EIO) from err
292 except Exception as e: 433 except Exception as exp:
293 logger.exception('Unable to parse %r', args.config_file) 434 logger.exception('Unable to parse %r', args.config_file)
294 raise SystemExit(errno.EIO) from e 435 raise SystemExit(errno.EIO) from exp
295 try: 436 try:
437 etoolkit_cli_handler = EtoolkitCLIHandler(args, config_dict)
296 if args.decrypt_value: 438 if args.decrypt_value:
297 decrypt_value(args, config_dict) 439 etoolkit_cli_handler.decrypt_value()
298 sys.exit(0) 440 sys.exit(0)
299 if args.encrypt_value: 441 if args.encrypt_value:
300 encrypt_value(args, config_dict) 442 etoolkit_cli_handler.encrypt_value()
301 sys.exit(0) 443 sys.exit(0)
302 if args.password_hash: 444 if args.password_hash:
303 master_password = ( 445 etoolkit_cli_handler.generate_master_password_hash()
304 etoolkit.EtoolkitInstance.confirm_password_prompt()
305 )
306 phash = etoolkit.EtoolkitInstance.get_new_password_hash(
307 master_password
308 )
309 print(f'Master password hash: {phash}')
310 sys.exit(0) 446 sys.exit(0)
311 if args.list: 447 if args.list:
312 for instance_name in sorted( 448 etoolkit_cli_handler.list()
313 filter( 449 sys.exit(0)
314 lambda s: not s.startswith('_'), 450 if args.reencrypt:
315 config_dict.get('instances', {}).keys(), 451 etoolkit_cli_handler.reencrypt()
316 )
317 ):
318 print(instance_name)
319 sys.exit(0) 452 sys.exit(0)
320 453
321 inst = etoolkit.EtoolkitInstance(args.instance, config_dict) 454 etoolkit_cli_handler.load_instance()
322 if (
323 args.master_password_prompt
324 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
325 ):
326 inst.prompt_func = (
327 etoolkit.EtoolkitInstance.confirm_password_prompt
328 )
329 env = inst.get_environ()
330
331 if args.dump_output:
332 inst.dump_env(env)
333
334 os.environ.update(env)
335
336 if args.spawn:
337 subprocess.run(args.spawn.split(), check=False)
338 else:
339 subprocess.run(
340 os.environ.get('SHELL', 'bash').split(), check=False
341 )
342 except KeyboardInterrupt: 455 except KeyboardInterrupt:
343 logger.debug('KeyboardInterrupt') 456 logger.debug('KeyboardInterrupt')
344 print(os.linesep) 457 print(os.linesep)
345 sys.exit(0) 458 sys.exit(0)
346 except etoolkit.EtoolkitInstanceError as e: 459 except etoolkit.EtoolkitInstanceError as err:
347 logger.error('EtoolkitInstanceError: %s', e) 460 logger.error('EtoolkitInstanceError: %s', err)
348 sys.exit(1) 461 sys.exit(1)
349 except subprocess.CalledProcessError as e: 462 except subprocess.CalledProcessError as err:
350 logger.error('Unable to spawn shell process: %s', e) 463 logger.error('Unable to spawn shell process: %s', err)
351 sys.exit(1) 464 sys.exit(1)
352 except Exception: 465 except Exception:
353 logger.exception('Unexpected exception') 466 logger.exception('Unexpected exception')
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py
index a2e0d7a..9a1b47e 100644
--- a/src/etoolkit/etoolkit.py
+++ b/src/etoolkit/etoolkit.py
@@ -23,7 +23,6 @@ import os
23from cryptography.exceptions import InvalidTag 23from cryptography.exceptions import InvalidTag
24from cryptography.hazmat.primitives.ciphers.aead import AESGCM 24from cryptography.hazmat.primitives.ciphers.aead import AESGCM
25 25
26
27MIN_ENCRYPTED_VALUE_LENGTH = 32 26MIN_ENCRYPTED_VALUE_LENGTH = 32
28 27
29 28
@@ -50,24 +49,26 @@ class EtoolkitInstance:
50 self._master_password_hash = None 49 self._master_password_hash = None
51 self._prompt_func = None # function to use when prompting for input 50 self._prompt_func = None # function to use when prompting for input
52 try: 51 try:
53 inst_data = data['instances'][name] 52 self._instance_data = data['instances'][name]
54 except KeyError as e: 53 except KeyError as err:
55 raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e 54 raise EtoolkitInstanceError(f'Unknown instance "{name}"') from err
56 if inst_data.get('ETOOLKIT_PARENT'): 55 if self._instance_data.get('ETOOLKIT_PARENT'):
57 self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data) 56 self._parent = EtoolkitInstance(
57 self._instance_data['ETOOLKIT_PARENT'], data
58 )
58 self._raw_env_variables.update(self._parent.raw_env_variables) 59 self._raw_env_variables.update(self._parent.raw_env_variables)
59 self._sensitive_env_variables.extend( 60 self._sensitive_env_variables.extend(
60 self._parent.sensitive_env_variables 61 self._parent.sensitive_env_variables
61 ) 62 )
62 if inst_data.get('ETOOLKIT_SENSITIVE'): 63 if self._instance_data.get('ETOOLKIT_SENSITIVE'):
63 if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list): 64 if not isinstance(self._instance_data['ETOOLKIT_SENSITIVE'], list):
64 raise EtoolkitInstanceError( 65 raise EtoolkitInstanceError(
65 '"ETOOLKIT_SENSITIVE" must be a list' 66 '"ETOOLKIT_SENSITIVE" must be a list'
66 ) 67 )
67 self._sensitive_env_variables.extend( 68 self._sensitive_env_variables.extend(
68 inst_data['ETOOLKIT_SENSITIVE'] 69 self._instance_data['ETOOLKIT_SENSITIVE']
69 ) 70 )
70 self._raw_env_variables.update(inst_data) 71 self._raw_env_variables.update(self._instance_data)
71 # remove non env. variable data 72 # remove non env. variable data
72 self._raw_env_variables.pop('ETOOLKIT_PARENT', None) 73 self._raw_env_variables.pop('ETOOLKIT_PARENT', None)
73 self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None) 74 self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None)
@@ -200,7 +201,7 @@ class EtoolkitInstance:
200 # padding_length_bytes(2 bytes) data padding (between 0 and 32) 201 # padding_length_bytes(2 bytes) data padding (between 0 and 32)
201 202
202 # extract padding_length_bytes 203 # extract padding_length_bytes
203 if data[:2] == b'--': 204 if data[:2] == b'-1' or data[:2] == b'--':
204 data = data[2:] 205 data = data[2:]
205 else: 206 else:
206 data = data[2 : -int(data[:2].decode())] 207 data = data[2 : -int(data[:2].decode())]
@@ -259,7 +260,7 @@ class EtoolkitInstance:
259 ) 260 )
260 ) 261 )
261 nonce = salt[:12] 262 nonce = salt[:12]
262 padding_length_bytes = b'--' # no padding used 2 bytes "sign" 263 padding_length_bytes = b'-1' # no padding used 2 bytes "sign"
263 edata = aesgcm.encrypt( 264 edata = aesgcm.encrypt(
264 nonce, padding_length_bytes + data_bytes, salt 265 nonce, padding_length_bytes + data_bytes, salt
265 ) 266 )
@@ -347,18 +348,49 @@ class EtoolkitInstance:
347 except Exception: 348 except Exception:
348 return False 349 return False
349 350
350 def dump_env(self, env: dict): 351 @staticmethod
352 def reencrypt(password: str, new_password: str, edata: str) -> str:
353 """
354 Re-encrypts `edata` using `password` and `new_password`.
355
356 Version 2 of the etoolkit encryption format
357
358 `edata` is in the following format:
359 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
360
361 :param password: The password to decrypt `edata` with
362 :type password: str
363
364 :param new_password: The password to re-encrypt the plain-text with
365 :type new_password: str
366
367 :param edata: The data to be re-encrypted
368 :type edata: str
369
370 :return: The new encrypted string string
371 :rtype: str
372 """
373 return EtoolkitInstance.encrypt(
374 new_password, EtoolkitInstance.decrypt(password, edata)
375 )
376
377 def env_to_str(self, env: dict) -> str:
351 """ 378 """
352 Prints an environment dict to stdout. 379 Returns a printable str. representation of the environment dict
353 380
354 :param env: The environment dict 381 :param env: The environment dict
355 :type env: dict 382 :type env: dict
383
384 :return: Printable representation of the environment dict
385 :rtype: str
356 """ 386 """
387 env_str = ''
357 for key, value in env.items(): 388 for key, value in env.items():
358 if key in self._sensitive_env_variables: 389 if key in self._sensitive_env_variables:
359 print(f'{key}: ***') 390 env_str += f'{key}: ***{os.linesep}'
360 continue 391 continue
361 print(f'{key}: {value}') 392 env_str += f'{key}: {value}{os.linesep}'
393 return env_str
362 394
363 def get_environ(self) -> dict: 395 def get_environ(self) -> dict:
364 """ 396 """
@@ -421,6 +453,50 @@ class EtoolkitInstance:
421 return self.name 453 return self.name
422 return self._parent.get_full_name(delimiter) + delimiter + self.name 454 return self._parent.get_full_name(delimiter) + delimiter + self.name
423 455
456 def get_reencrypted_instance_data(
457 self, new_password: str, password: str = None
458 ) -> dict:
459 """
460 Returns new instance data (dict) containing new encrypted values
461
462 Each encrypted value in this instance is decrypted using `password`
463 and then encrypted again using `new_password`
464
465 If `password` is None, master_password is not set earlier for this
466 instance and 'ETOOLKIT_MASTER_PASSWORD' is not set,
467 the prompt function will be called
468
469 :param new_password: The password to reencrypt with
470 :type new_password: str
471
472 :param password: The password to decrypt current encrypted values with
473 :type password: str or None
474
475 :return: New instance data
476 :rtype: dict
477 """
478 if password is None:
479 password = self._master_password
480
481 if password is None and self._prompt_func is None:
482 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
483 if password is None:
484 raise EtoolkitInstanceError(
485 'Neither password or prompt function set'
486 )
487
488 if password is None:
489 password = self._prompt_func(
490 self._master_password_hash, confirm=False
491 )
492
493 new_data = dict(self._instance_data)
494 for key, value in self._instance_data.items():
495 if isinstance(value, str) and value.startswith('enc-val$'):
496 new_data[key] = self.reencrypt(password, new_password, value)
497
498 return new_data
499
424 def _decrypt_value(self, evalue: str) -> str: 500 def _decrypt_value(self, evalue: str) -> str:
425 """ 501 """
426 Decrypts an encrypted value using the master password 502 Decrypts an encrypted value using the master password
@@ -452,4 +528,4 @@ class EtoolkitInstance:
452 self.master_password = self._prompt_func( 528 self.master_password = self._prompt_func(
453 self._master_password_hash, confirm=False 529 self._master_password_hash, confirm=False
454 ) 530 )
455 return EtoolkitInstance.decrypt(self._master_password, evalue) 531 return self.decrypt(self._master_password, evalue)
diff --git a/tests/conftest.py b/tests/conftest.py
index cbf7152..37dd184 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -45,9 +45,9 @@ def config_data():
45 'ETOOLKIT_SENSITIVE': ['ETOOLKIT_TEST_PASSWORD'], 45 'ETOOLKIT_SENSITIVE': ['ETOOLKIT_TEST_PASSWORD'],
46 'GNUPGHOME': '%h/private/.gnupg', 46 'GNUPGHOME': '%h/private/.gnupg',
47 'ETOOLKIT_TEST_PASSWORD': ( 47 'ETOOLKIT_TEST_PASSWORD': (
48 'enc-val$2$v6F2M7LeUDbQWNLg6WW5mUcbuYYo7aGynSxzWAENVBI=$' 48 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$'
49 'ZcyWzf9Kp0aYI8N+biKMSmu4RGGi199ayq' 49 '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqz'
50 'EYdJl+qdq7b1HSutwYlC7UR2GsSofu4Xo=' 50 'oIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U='
51 ), 51 ),
52 }, 52 },
53 }, 53 },
@@ -64,12 +64,35 @@ def config_file(tmp_path, config_data):
64 64
65 65
66@pytest.fixture() 66@pytest.fixture()
67def long_encrypted_value():
68 """enc. value corresponding to 'Nobody expects the Spanish inquisition'"""
69
70 return (
71 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$'
72 'UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hF'
73 'Sujpe35X/XpzBegJJpo86AiCsNsUS6B6JM='
74 )
75
76
77@pytest.fixture()
78def long_value():
79 """standard value (> 32 bytes)"""
80 return 'Nobody expects the Spanish inquisition'
81
82
83@pytest.fixture()
67def master_password(): 84def master_password():
68 """Master passord""" 85 """Master passord"""
69 return 'The very secret passwd' 86 return 'The very secret passwd'
70 87
71 88
72@pytest.fixture() 89@pytest.fixture()
90def new_master_password():
91 """Master passord"""
92 return 'New very secret passwd'
93
94
95@pytest.fixture()
73def non_random_bytes_32(): 96def non_random_bytes_32():
74 """always use the same bytes instead of os.urandom(32)""" 97 """always use the same bytes instead of os.urandom(32)"""
75 98
@@ -80,13 +103,13 @@ def non_random_bytes_32():
80 103
81 104
82@pytest.fixture() 105@pytest.fixture()
83def non_random_bytes_61(): 106def non_random_bytes_57():
84 """always use the same bytes instead of os.urandom(61)""" 107 """always use the same bytes instead of os.urandom(57)"""
85 108
86 return ( 109 return (
87 b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa' 110 b'D$\x99\xaa\xafiZ\xb4C\xa0%XTz)\xca\xedK\xcd\xa2F~\xff+\xa1[\xe2\xaa'
88 b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e' 111 b'\xb2\xd3\x07\x13\xedb\xc2\x84\xfe\tS\r\xf0\x02_\xef\xe3\xde\xf1?e'
89 b'\xa4s(Q\x04\xcd\xc7T\x01_D\xb1' 112 b'\xa4s(Q\x04\xcd\xc7T'
90 ) 113 )
91 114
92 115
@@ -108,6 +131,32 @@ def password_hash():
108 131
109 132
110@pytest.fixture() 133@pytest.fixture()
134def short_encrypted_value():
135 """enc. value corresponding to 'secret1'"""
136
137 return (
138 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$'
139 '+YYrZbwTBuG0Pl+WMQrvxLUtq5j8qYuQqzoIwgoGt7AaWZCJz+E7qoDeg3wke70ST8U='
140 )
141
142
143@pytest.fixture()
144def short_encrypted_value_v1():
145 """enc. value (enc-val 1) corresponding to 'secret1'"""
146
147 return (
148 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx'
149 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY='
150 )
151
152
153@pytest.fixture()
154def short_value():
155 """standard value (< 32 bytes)"""
156 return 'secret1'
157
158
159@pytest.fixture()
111def wrong_master_password(): 160def wrong_master_password():
112 """Wrong master passord""" 161 """Wrong master passord"""
113 return 'the very secret passwd' 162 return 'the very secret passwd'
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1693a2b..953abf4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -44,14 +44,17 @@ def test_decrypt_v1(binput, capsys, config_file, master_password):
44 44
45 45
46@unittest.mock.patch('builtins.input') 46@unittest.mock.patch('builtins.input')
47def test_decrypt_v2(binput, capsys, config_file, master_password): 47def test_decrypt_v2(
48 binput,
49 capsys,
50 config_file,
51 master_password,
52 short_encrypted_value,
53 short_value,
54):
48 """Tests v2 decryption via the CLI interface""" 55 """Tests v2 decryption via the CLI interface"""
49 56
50 binput.return_value = ( 57 binput.return_value = short_encrypted_value
51 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq'
52 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E'
53 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4='
54 )
55 with unittest.mock.patch.dict( 58 with unittest.mock.patch.dict(
56 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} 59 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password}
57 ): 60 ):
@@ -59,18 +62,27 @@ def test_decrypt_v2(binput, capsys, config_file, master_password):
59 main(['-c', f'{config_file}', '-d']) 62 main(['-c', f'{config_file}', '-d'])
60 assert exit_info.type == SystemExit 63 assert exit_info.type == SystemExit
61 assert exit_info.value.code == 0 64 assert exit_info.value.code == 0
62 assert capsys.readouterr().out.strip() == 'Decrypted value: bar' 65 assert capsys.readouterr().out.strip() == (
66 f'Decrypted value: {short_value}'
67 )
63 68
64 69
65@unittest.mock.patch('os.urandom') 70@unittest.mock.patch('os.urandom')
66@unittest.mock.patch('builtins.input') 71@unittest.mock.patch('builtins.input')
67def test_encrypt_with_echo( 72def test_encrypt_with_echo(
68 binput, urandom, capsys, non_random_bytes_61, config_file, master_password 73 binput,
74 urandom,
75 capsys,
76 non_random_bytes_57,
77 config_file,
78 master_password,
79 short_encrypted_value,
80 short_value,
69): 81):
70 """Tests encryption via the CLI interface""" 82 """Tests encryption via the CLI interface"""
71 83
72 binput.return_value = 'bar' 84 binput.return_value = short_value
73 urandom.return_value = non_random_bytes_61 85 urandom.return_value = non_random_bytes_57
74 with unittest.mock.patch.dict( 86 with unittest.mock.patch.dict(
75 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} 87 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password}
76 ): 88 ):
@@ -79,21 +91,26 @@ def test_encrypt_with_echo(
79 assert exit_info.type == SystemExit 91 assert exit_info.type == SystemExit
80 assert exit_info.value.code == 0 92 assert exit_info.value.code == 0
81 assert capsys.readouterr().out.strip() == ( 93 assert capsys.readouterr().out.strip() == (
82 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' 94 f'Encrypted value: {short_encrypted_value}'
83 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E'
84 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4='
85 ) 95 )
86 96
87 97
88@unittest.mock.patch('os.urandom') 98@unittest.mock.patch('os.urandom')
89@unittest.mock.patch('getpass.getpass') 99@unittest.mock.patch('getpass.getpass')
90def test_encrypt_without_echo( 100def test_encrypt_without_echo(
91 getpass, urandom, capsys, non_random_bytes_61, config_file, master_password 101 getpass,
102 urandom,
103 capsys,
104 non_random_bytes_57,
105 config_file,
106 master_password,
107 short_encrypted_value,
108 short_value,
92): 109):
93 """Tests encryption via the CLI interface""" 110 """Tests encryption via the CLI interface"""
94 111
95 getpass.return_value = 'bar' 112 getpass.return_value = short_value
96 urandom.return_value = non_random_bytes_61 113 urandom.return_value = non_random_bytes_57
97 with unittest.mock.patch.dict( 114 with unittest.mock.patch.dict(
98 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password} 115 os.environ, {'ETOOLKIT_MASTER_PASSWORD': master_password}
99 ): 116 ):
@@ -102,13 +119,11 @@ def test_encrypt_without_echo(
102 assert exit_info.type == SystemExit 119 assert exit_info.type == SystemExit
103 assert exit_info.value.code == 0 120 assert exit_info.value.code == 0
104 assert capsys.readouterr().out.strip() == ( 121 assert capsys.readouterr().out.strip() == (
105 'Encrypted value: enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviq' 122 f'Encrypted value: {short_encrypted_value}'
106 'rLTBxM=$+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/E'
107 'SvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4='
108 ) 123 )
109 124
110 125
111def test_fetch_encrypted_value(config_file, master_password): 126def test_fetch_encrypted_value(config_file, master_password, short_value):
112 """Tests decryption of encrypted value""" 127 """Tests decryption of encrypted value"""
113 128
114 with unittest.mock.patch.dict( 129 with unittest.mock.patch.dict(
@@ -116,7 +131,7 @@ def test_fetch_encrypted_value(config_file, master_password):
116 ): 131 ):
117 assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None 132 assert os.environ.get('ETOOLKIT_TEST_PASSWORD') is None
118 main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret']) 133 main(['-c', f'{config_file}', '-q', '-s', '/bin/false', 'secret'])
119 assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == 'bar' 134 assert os.environ.get('ETOOLKIT_TEST_PASSWORD') == short_value
120 135
121 136
122def test_list(capsys, config_file, nonexistent_config_file): 137def test_list(capsys, config_file, nonexistent_config_file):
diff --git a/tests/test_envtoolkit_instance.py b/tests/test_envtoolkit_instance.py
index 0147c6f..9873d57 100644
--- a/tests/test_envtoolkit_instance.py
+++ b/tests/test_envtoolkit_instance.py
@@ -41,7 +41,9 @@ def test_instantiation(config_data):
41 assert instance.master_password is None 41 assert instance.master_password is None
42 42
43 43
44def test_get_environ(config_data, master_password, wrong_master_password): 44def test_get_environ(
45 config_data, master_password, short_value, wrong_master_password
46):
45 """Tests the EtoolkitInstance.get_environ method""" 47 """Tests the EtoolkitInstance.get_environ method"""
46 48
47 instance = etoolkit.EtoolkitInstance('secret', config_data) 49 instance = etoolkit.EtoolkitInstance('secret', config_data)
@@ -61,7 +63,7 @@ def test_get_environ(config_data, master_password, wrong_master_password):
61 instance.master_password = master_password 63 instance.master_password = master_password
62 env = instance.get_environ() 64 env = instance.get_environ()
63 assert isinstance(env, dict) 65 assert isinstance(env, dict)
64 assert env['ETOOLKIT_TEST_PASSWORD'] == 'bar' 66 assert env['ETOOLKIT_TEST_PASSWORD'] == short_value
65 67
66 68
67def test_get_full_name(config_data): 69def test_get_full_name(config_data):
diff --git a/tests/test_envtoolkit_instance_static.py b/tests/test_envtoolkit_instance_static.py
index b3b24f0..ee5cf9e 100644
--- a/tests/test_envtoolkit_instance_static.py
+++ b/tests/test_envtoolkit_instance_static.py
@@ -37,18 +37,14 @@ def test_confirm_password_prompt(getpass, password_hash, master_password):
37 ) 37 )
38 38
39 39
40def test_decrypt_v1(master_password): 40def test_decrypt_v1(master_password, short_encrypted_value_v1, short_value):
41 """Tests the static EtoolkitInstance.decrypt method""" 41 """Tests the static EtoolkitInstance.decrypt method"""
42 42
43 assert ( 43 assert (
44 etoolkit.EtoolkitInstance.decrypt( 44 etoolkit.EtoolkitInstance.decrypt(
45 master_password, 45 master_password, short_encrypted_value_v1
46 (
47 'enc-val$1$/cXpEMoZrTlb9yokGhw8tLTSUkqnqJ4ZoAkurNgMYx'
48 'w=$1VdkSMcZnLRwLiu1M8VlYcbelwmiVNY='
49 ),
50 ) 46 )
51 == 'secret1' 47 == short_value
52 ) 48 )
53 49
54 # now test with modified edata 50 # now test with modified edata
@@ -62,117 +58,106 @@ def test_decrypt_v1(master_password):
62 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' 58 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
63 59
64 60
65def test_decrypt_v2_no_padding(master_password): 61def test_decrypt_v2_no_padding(
62 master_password, long_encrypted_value, long_value
63):
66 """Tests the static EtoolkitInstance.decrypt method for v2 - no padding""" 64 """Tests the static EtoolkitInstance.decrypt method for v2 - no padding"""
67 65
68 assert ( 66 assert (
69 etoolkit.EtoolkitInstance.decrypt( 67 etoolkit.EtoolkitInstance.decrypt(
70 master_password, 68 master_password, long_encrypted_value
71 (
72 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$'
73 'SQc0ox6Emf2m5rrumsiptpIZEujdpXXSR/'
74 '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8='
75 ),
76 ) 69 )
77 == 'Nobody expects the Spanish inquisition' 70 == long_value
78 ) 71 )
79 72
80 # now test with modified edata 73 # now test with modified encrypted data
81 edata = ( 74 edata = long_encrypted_value[:60] + '5' + long_encrypted_value[61:]
82 'enc-val$2$Wer5lECGyeZhhYS58N18WVx5Zzy+rrC+BPlq3Dw89wQ=$' 75
83 'SQc0ox6Emf2m4rrumsiptpIZEujdpXXSR/'
84 '1VcfEZeBz4+KDSagr9ID+bkc4R2yFdxHnhig1eqQ8='
85 )
86 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: 76 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
87 etoolkit.EtoolkitInstance.decrypt(master_password, edata) 77 etoolkit.EtoolkitInstance.decrypt(master_password, edata)
88 assert exc_info.type is etoolkit.EtoolkitInstanceError 78 assert exc_info.type is etoolkit.EtoolkitInstanceError
89 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' 79 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
90 80
91 81
92def test_decrypt_v2_with_padding(master_password): 82def test_decrypt_v2_with_padding(
83 master_password, short_encrypted_value, short_value
84):
93 """Tests the static EtoolkitInstance.decrypt method for v2 with padding""" 85 """Tests the static EtoolkitInstance.decrypt method for v2 with padding"""
94 86
95 assert ( 87 assert (
96 etoolkit.EtoolkitInstance.decrypt( 88 etoolkit.EtoolkitInstance.decrypt(
97 master_password, 89 master_password, short_encrypted_value
98 (
99 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$'
100 'rhRona4wP9nhnXjcHqwkjFDsiVVVjYanAs'
101 'N4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w='
102 ),
103 ) 90 )
104 == 'secret1' 91 == short_value
105 ) 92 )
106 93
107 # now test with modified edata 94 # now test with modified edata
108 edata = ( 95 edata = short_encrypted_value[:60] + '5' + short_encrypted_value[61:]
109 'enc-val$2$//kzyUbDEWNoPC5dyukhB8de8+IVaLR2ngx2HwkfOuM=$'
110 'rhRona4wP8nhnXjcHqwkjFDsiVVVjYanAsN4kknNkgC0ix4RtJQHYDeTzw1rrR1vb2w='
111 )
112 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info: 96 with pytest.raises(etoolkit.EtoolkitInstanceError) as exc_info:
113 etoolkit.EtoolkitInstance.decrypt(master_password, edata) 97 etoolkit.EtoolkitInstance.decrypt(master_password, edata)
114 assert exc_info.type is etoolkit.EtoolkitInstanceError 98 assert exc_info.type is etoolkit.EtoolkitInstanceError
115 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}' 99 assert exc_info.value.args[0] == f'Invalid tag when decrypting: {edata}'
116 100
117 101
118def test_encrypt_no_padding(master_password): 102def test_encrypt_no_padding(master_password, long_value):
119 """Tests the static EtoolkitInstance.encrypt method with a long string""" 103 """Tests the static EtoolkitInstance.encrypt method with a long string"""
120 104
121 edata = etoolkit.EtoolkitInstance.encrypt( 105 edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value)
122 master_password, 'Nobody expects the Spanish inquisition'
123 )
124 assert edata.startswith('enc-val$2$') 106 assert edata.startswith('enc-val$2$')
125 assert len(edata) == 131 107 assert len(edata) == 131
126 # the edata should always be different because of random salting 108 # the edata should always be different because of random salting
127 assert edata != etoolkit.EtoolkitInstance.encrypt( 109 assert edata != etoolkit.EtoolkitInstance.encrypt(
128 master_password, 'Nobody expects the Spanish inquisition' 110 master_password, long_value
129 ) 111 )
130 112
131 113
132def test_encrypt_with_padding(master_password): 114def test_encrypt_with_padding(master_password, short_value):
133 """Tests the static EtoolkitInstance.encrypt method with a short string""" 115 """Tests the static EtoolkitInstance.encrypt method with a short string"""
134 116
135 edata = etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') 117 edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value)
136 assert edata.startswith('enc-val$2$') 118 assert edata.startswith('enc-val$2$')
137 assert len(edata) == 123 119 assert len(edata) == 123
138 # the edata should always be different because of random salting 120 # the edata should always be different because of random salting
139 assert edata != etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') 121 assert edata != etoolkit.EtoolkitInstance.encrypt(
122 master_password, short_value
123 )
140 124
141 125
142@unittest.mock.patch('os.urandom') 126@unittest.mock.patch('os.urandom')
143def test_encrypt_staticly_no_padding( 127def test_encrypt_staticly_no_padding(
144 urandom, master_password, non_random_bytes_32 128 urandom,
129 master_password,
130 non_random_bytes_32,
131 long_encrypted_value,
132 long_value,
145): 133):
146 """Tests the EtoolkitInstance.encrypt method always with the same salt""" 134 """Tests the EtoolkitInstance.encrypt method always with the same salt"""
147 135
148 urandom.return_value = non_random_bytes_32 136 urandom.return_value = non_random_bytes_32
149 edata = etoolkit.EtoolkitInstance.encrypt( 137 edata = etoolkit.EtoolkitInstance.encrypt(master_password, long_value)
150 master_password, 'Nobody expects the Spanish inquisition' 138 assert edata == long_encrypted_value
151 )
152 assert edata == (
153 'enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$'
154 'UX/5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmC'
155 'o8hFSujpe35X/XpzAiYv4BV1LNwnSYECsotsgs='
156 )
157 assert len(edata) == 131 139 assert len(edata) == 131
158 assert edata == etoolkit.EtoolkitInstance.encrypt( 140 assert edata == etoolkit.EtoolkitInstance.encrypt(
159 master_password, 'Nobody expects the Spanish inquisition' 141 master_password, long_value
160 ) 142 )
161 143
162 144
163@unittest.mock.patch('os.urandom') 145@unittest.mock.patch('os.urandom')
164def test_encrypt_staticly_with_padding( 146def test_encrypt_staticly_with_padding(
165 urandom, master_password, non_random_bytes_61 147 urandom,
148 master_password,
149 non_random_bytes_57,
150 short_encrypted_value,
151 short_value,
166): 152):
167 """Tests the EtoolkitInstance.encrypt method always with the same salt""" 153 """Tests the EtoolkitInstance.encrypt method always with the same salt"""
168 154
169 urandom.return_value = non_random_bytes_61 155 urandom.return_value = non_random_bytes_57
170 edata = etoolkit.EtoolkitInstance.encrypt(master_password, 'bar') 156 edata = etoolkit.EtoolkitInstance.encrypt(master_password, short_value)
171 assert edata == ( 157 assert edata == short_encrypted_value
172 'enc-val$2$RCSZqq9pWrRDoCVYVHopyu1LzaJGfv8roVviqrLTBxM=$' 158 assert edata == etoolkit.EtoolkitInstance.encrypt(
173 '+Yo6Ya2MAVcBLTQHuATkyFc+dzYsL/ESvA6ofOUDsiKZvIff35cUHAmoNxVuGG+MXv4=' 159 master_password, short_value
174 ) 160 )
175 assert edata == etoolkit.EtoolkitInstance.encrypt(master_password, 'bar')
176 161
177 162
178def test_get_new_password_hash(master_password): 163def test_get_new_password_hash(master_password):
@@ -208,3 +193,38 @@ def test_password_matches(
208 assert not etoolkit.EtoolkitInstance.password_matches( 193 assert not etoolkit.EtoolkitInstance.password_matches(
209 wrong_master_password, password_hash 194 wrong_master_password, password_hash
210 ) 195 )
196
197
198@unittest.mock.patch('os.urandom')
199def test_reencrypt_staticly_with_padding(
200 urandom,
201 master_password,
202 new_master_password,
203 non_random_bytes_57,
204 short_encrypted_value,
205 short_encrypted_value_v1,
206 short_value,
207):
208 """Tests the EtoolkitInstance.reencrypt method always with the same salt"""
209
210 urandom.return_value = non_random_bytes_57
211 # reencrypt (migrate) v1 to current using the same password
212 edata = etoolkit.EtoolkitInstance.reencrypt(
213 master_password, master_password, short_encrypted_value_v1
214 )
215 assert edata == short_encrypted_value
216
217 # same version, same salt, same edata
218 assert edata == etoolkit.EtoolkitInstance.reencrypt(
219 master_password, master_password, edata
220 )
221
222 # use different password
223 edata = etoolkit.EtoolkitInstance.reencrypt(
224 master_password, new_master_password, edata
225 )
226 assert edata != short_encrypted_value
227 assert (
228 etoolkit.EtoolkitInstance.decrypt(new_master_password, edata)
229 == short_value
230 )