diff options
| author | Simeon Simeonov | 2024-05-13 21:52:13 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2024-05-13 21:52:13 +0200 |
| commit | 08a3280b062af83ee50fa139d7827d954907886e (patch) | |
| tree | 8e1d3f3117d05b3ad779d070ec30ddbbe242e62b | |
| parent | bb9a844e22134a2537652ea14f93e82acb4ee380 (diff) | |
Implement re-encryption support2.0.0
| -rw-r--r-- | .ruff.toml | 73 | ||||
| -rw-r--r-- | CHANGELOG.md | 15 | ||||
| -rw-r--r-- | README.md | 133 | ||||
| -rw-r--r-- | completion/etoolkit.bash | 7 | ||||
| -rw-r--r-- | etoolkit_sample.json | 16 | ||||
| -rw-r--r-- | src/etoolkit/__init__.py | 2 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 371 | ||||
| -rw-r--r-- | src/etoolkit/etoolkit.py | 110 | ||||
| -rw-r--r-- | tests/conftest.py | 61 | ||||
| -rw-r--r-- | tests/test_cli.py | 57 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance.py | 6 | ||||
| -rw-r--r-- | tests/test_envtoolkit_instance_static.py | 136 |
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 @@ | |||
| 1 | cache-dir = "~/.cache/ruff" | ||
| 2 | indent-width = 4 | ||
| 3 | line-length = 79 | ||
| 4 | target-version = "py312" | ||
| 5 | |||
| 6 | [lint] | ||
| 7 | select = ["ALL"] | ||
| 8 | ignore = ["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. | ||
| 42 | fixable = ["ALL"] | ||
| 43 | unfixable = [] | ||
| 44 | |||
| 45 | [format] | ||
| 46 | # Like Black, use double quotes for strings. | ||
| 47 | quote-style = "single" | ||
| 48 | |||
| 49 | # Like Black, indent with spaces, rather than tabs. | ||
| 50 | indent-style = "space" | ||
| 51 | |||
| 52 | # Like Black, respect magic trailing commas. | ||
| 53 | skip-magic-trailing-comma = true | ||
| 54 | |||
| 55 | # Like Black, automatically detect the appropriate line ending. | ||
| 56 | line-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. | ||
| 63 | docstring-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. | ||
| 70 | docstring-code-line-length = "dynamic" | ||
| 71 | |||
| 72 | [lint.flake8-quotes] | ||
| 73 | inline-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) |
| @@ -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 | |||
| 77 | The etoolkit encryption format is currently at version 2. | ||
| 78 | Encrypted values start with *enc-val$2$*. | ||
| 79 | |||
| 80 | This new version introduces padding for values that are shorter than 32 bytes. | ||
| 81 | The idea behind padding is to generate (32 - value length) random bytes and | ||
| 82 | append them to the original value. | ||
| 83 | That prevents a potential attacker from knowing the length of the encrypted | ||
| 84 | short value (f.i. password, PIN number, username... etc). | ||
| 85 | |||
| 86 | Values encrypted in the old format (*enc-val$1$*) can still be decrypted | ||
| 87 | seamlessly. | ||
| 88 | |||
| 89 | Authenticated encryption with associated data (AEAD) is implemented using | ||
| 90 | AES-GCM. | ||
| 91 | |||
| 92 | |||
| 93 | ### Encryption | ||
| 94 | |||
| 95 | Input: | ||
| 96 | |||
| 97 | - plain-text value to be encrypted (P) | ||
| 98 | |||
| 99 | - plain-text master-password used for key derivation (M) | ||
| 100 | |||
| 101 | |||
| 102 | Output: | ||
| 103 | |||
| 104 | - an encrypted value digest (base64) (B) | ||
| 105 | |||
| 106 | |||
| 107 | Operation: | ||
| 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 | |||
| 125 | example: | ||
| 126 | enc-val$2$uYpZM1VfAGq0CDZL2duITs076CQj+hIFEgx+F4mn80o=$UWP5YeRsh5/2vZ2J1UOS+BJti73Kbp6C1pJmCo8hFSujpe35X/XpzBegJJpo86AiCsNsUS6B6JM= | ||
| 127 | |||
| 128 | |||
| 129 | ### Decryption | ||
| 130 | |||
| 131 | Input: | ||
| 132 | |||
| 133 | - encrypted value digest (base64) (B) | ||
| 134 | |||
| 135 | - plain-text master-password used for key derivation (M) | ||
| 136 | |||
| 137 | |||
| 138 | Output: | ||
| 139 | |||
| 140 | - plain-text password (P) | ||
| 141 | |||
| 142 | |||
| 143 | Operation: | ||
| 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 | ||
| 289 | It is possible to re-encrypt all encrypted values in a specific instance or in | ||
| 290 | all defined instances either by using the same or a new master password. | ||
| 291 | |||
| 292 | ```bash | ||
| 293 | etoolkit --reencrypt all | ||
| 294 | ``` | ||
| 295 | |||
| 296 | will 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 | ||
| 298 | contents will be displayed. | ||
| 299 | |||
| 204 | Contact the author for questions and suggestions! :) | 300 | Contact 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 @@ | |||
| 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | 18 | from .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 | |||
| 42 | logger = logging.getLogger(__name__) | 42 | logger = logging.getLogger(__name__) |
| 43 | 43 | ||
| 44 | 44 | ||
| 45 | def decrypt_value(args: argparse.Namespace, config: dict): | 45 | class 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 | ||
| 98 | def 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 | ||
| 154 | def main(inargs=None): | 280 | def 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 | |||
| 23 | from cryptography.exceptions import InvalidTag | 23 | from cryptography.exceptions import InvalidTag |
| 24 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | 24 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM |
| 25 | 25 | ||
| 26 | |||
| 27 | MIN_ENCRYPTED_VALUE_LENGTH = 32 | 26 | MIN_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() |
| 67 | def 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() | ||
| 78 | def long_value(): | ||
| 79 | """standard value (> 32 bytes)""" | ||
| 80 | return 'Nobody expects the Spanish inquisition' | ||
| 81 | |||
| 82 | |||
| 83 | @pytest.fixture() | ||
| 67 | def master_password(): | 84 | def 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() |
| 90 | def new_master_password(): | ||
| 91 | """Master passord""" | ||
| 92 | return 'New very secret passwd' | ||
| 93 | |||
| 94 | |||
| 95 | @pytest.fixture() | ||
| 73 | def non_random_bytes_32(): | 96 | def 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() |
| 83 | def non_random_bytes_61(): | 106 | def 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() |
| 134 | def 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() | ||
| 144 | def 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() | ||
| 154 | def short_value(): | ||
| 155 | """standard value (< 32 bytes)""" | ||
| 156 | return 'secret1' | ||
| 157 | |||
| 158 | |||
| 159 | @pytest.fixture() | ||
| 111 | def wrong_master_password(): | 160 | def 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') |
| 47 | def test_decrypt_v2(binput, capsys, config_file, master_password): | 47 | def 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') |
| 67 | def test_encrypt_with_echo( | 72 | def 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') |
| 90 | def test_encrypt_without_echo( | 100 | def 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 | ||
| 111 | def test_fetch_encrypted_value(config_file, master_password): | 126 | def 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 | ||
| 122 | def test_list(capsys, config_file, nonexistent_config_file): | 137 | def 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 | ||
| 44 | def test_get_environ(config_data, master_password, wrong_master_password): | 44 | def 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 | ||
| 67 | def test_get_full_name(config_data): | 69 | def 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 | ||
| 40 | def test_decrypt_v1(master_password): | 40 | def 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 | ||
| 65 | def test_decrypt_v2_no_padding(master_password): | 61 | def 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 | ||
| 92 | def test_decrypt_v2_with_padding(master_password): | 82 | def 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 | ||
| 118 | def test_encrypt_no_padding(master_password): | 102 | def 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 | ||
| 132 | def test_encrypt_with_padding(master_password): | 114 | def 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') |
| 143 | def test_encrypt_staticly_no_padding( | 127 | def 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') |
| 164 | def test_encrypt_staticly_with_padding( | 146 | def 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 | ||
| 178 | def test_get_new_password_hash(master_password): | 163 | def 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') | ||
| 199 | def 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 | ) | ||
