diff options
| author | Simeon Simeonov | 2024-04-30 11:39:20 +0200 |
|---|---|---|
| committer | Simeon Simeonov | 2024-04-30 11:39:20 +0200 |
| commit | c31fa58c85e866b3a5ab04882c7aff655f0b5477 (patch) | |
| tree | 84532e1b6ce24c07267d4a8d8e8f810ba657500c /src | |
| parent | 8ca7a0d9666ede2d9d270e2a469e4b8dda492e69 (diff) | |
Implement etoolkit encryption protocol v2
Diffstat (limited to 'src')
| -rw-r--r-- | src/etoolkit/__init__.py | 5 | ||||
| -rw-r--r-- | src/etoolkit/__main__.py | 23 | ||||
| -rw-r--r--[-rwxr-xr-x] | src/etoolkit/etoolkit.py | 80 |
3 files changed, 71 insertions, 37 deletions
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py index b49bbcf..0ef5957 100644 --- a/src/etoolkit/__init__.py +++ b/src/etoolkit/__init__.py | |||
| @@ -1,5 +1,5 @@ | |||
| 1 | # etoolkit | 1 | # etoolkit |
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | 2 | # Copyright (C) 2021-2024 Simeon Simeonov |
| 3 | 3 | ||
| 4 | # This program is free software: you can redistribute it and/or modify | 4 | # This program is free software: you can redistribute it and/or modify |
| 5 | # it under the terms of the GNU General Public License as published by | 5 | # it under the terms of the GNU General Public License as published by |
| @@ -14,10 +14,11 @@ | |||
| 14 | # You should have received a copy of the GNU General Public License | 14 | # You should have received a copy of the GNU General Public License |
| 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 16 | """A simple toolkit for setting environment variables in a flexible way""" | 16 | """A simple toolkit for setting environment variables in a flexible way""" |
| 17 | |||
| 17 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError | 18 | from .etoolkit import EtoolkitInstance, EtoolkitInstanceError |
| 18 | 19 | ||
| 19 | __author__ = 'Simeon Simeonov' | 20 | __author__ = 'Simeon Simeonov' |
| 20 | __version__ = '1.2.0' | 21 | __version__ = '1.3.0' |
| 21 | __license__ = 'GPL3' | 22 | __license__ = 'GPL3' |
| 22 | 23 | ||
| 23 | 24 | ||
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py index a200453..fba9dc7 100644 --- a/src/etoolkit/__main__.py +++ b/src/etoolkit/__main__.py | |||
| @@ -1,5 +1,5 @@ | |||
| 1 | # etoolkit | 1 | # etoolkit |
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | 2 | # Copyright (C) 2021-2024 Simeon Simeonov |
| 3 | 3 | ||
| 4 | # This program is free software: you can redistribute it and/or modify | 4 | # This program is free software: you can redistribute it and/or modify |
| 5 | # it under the terms of the GNU General Public License as published by | 5 | # it under the terms of the GNU General Public License as published by |
| @@ -16,11 +16,14 @@ | |||
| 16 | """ | 16 | """ |
| 17 | CLI entry point for the etoolkit package | 17 | CLI entry point for the etoolkit package |
| 18 | 18 | ||
| 19 | Examples: | 19 | Examples |
| 20 | -------- | ||
| 20 | python -m etoolkit -h | 21 | python -m etoolkit -h |
| 21 | 22 | ||
| 22 | python -m etoolkit -p | 23 | python -m etoolkit -p |
| 24 | |||
| 23 | """ | 25 | """ |
| 26 | |||
| 24 | import argparse | 27 | import argparse |
| 25 | import errno | 28 | import errno |
| 26 | import getpass | 29 | import getpass |
| @@ -33,7 +36,7 @@ import sys | |||
| 33 | 36 | ||
| 34 | import etoolkit | 37 | import etoolkit |
| 35 | 38 | ||
| 36 | DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" | 39 | DEFAULT_LOG_FORMAT = '%(levelname)s: %(message)s' |
| 37 | DEFAULT_LOG_LEVEL = logging.WARNING | 40 | DEFAULT_LOG_LEVEL = logging.WARNING |
| 38 | 41 | ||
| 39 | logger = logging.getLogger(__name__) | 42 | logger = logging.getLogger(__name__) |
| @@ -269,7 +272,7 @@ def main(inargs=None): | |||
| 269 | ) | 272 | ) |
| 270 | args = parser.parse_args(inargs) | 273 | args = parser.parse_args(inargs) |
| 271 | try: | 274 | try: |
| 272 | with io.open(args.config_file, 'r', encoding='utf-8') as fp: | 275 | with io.open(args.config_file, encoding='utf-8') as fp: |
| 273 | config_dict = json.load(fp) | 276 | config_dict = json.load(fp) |
| 274 | except FileNotFoundError as e: | 277 | except FileNotFoundError as e: |
| 275 | # do not raise exception if config-file is missing for: | 278 | # do not raise exception if config-file is missing for: |
| @@ -278,16 +281,16 @@ def main(inargs=None): | |||
| 278 | # - password hash generation | 281 | # - password hash generation |
| 279 | if args.password_hash or args.decrypt_value or args.encrypt_value: | 282 | if args.password_hash or args.decrypt_value or args.encrypt_value: |
| 280 | logger.warning( | 283 | logger.warning( |
| 281 | "Configuration file %s is missing, although not required " | 284 | 'Configuration file %s is missing, although not required ' |
| 282 | "by the provided parameters", | 285 | 'by the provided parameters', |
| 283 | args.config_file, | 286 | args.config_file, |
| 284 | ) | 287 | ) |
| 285 | config_dict = {} | 288 | config_dict = {} |
| 286 | else: | 289 | else: |
| 287 | logger.error("Configuration file %s is missing", args.config_file) | 290 | logger.error('Configuration file %s is missing', args.config_file) |
| 288 | raise SystemExit(errno.EIO) from e | 291 | raise SystemExit(errno.EIO) from e |
| 289 | except Exception as e: | 292 | except Exception as e: |
| 290 | logger.error("Unable to parse %r: %s", args.config_file, e) | 293 | logger.exception('Unable to parse %r', args.config_file) |
| 291 | raise SystemExit(errno.EIO) from e | 294 | raise SystemExit(errno.EIO) from e |
| 292 | try: | 295 | try: |
| 293 | if args.decrypt_value: | 296 | if args.decrypt_value: |
| @@ -338,8 +341,8 @@ def main(inargs=None): | |||
| 338 | except subprocess.CalledProcessError as e: | 341 | except subprocess.CalledProcessError as e: |
| 339 | logger.error('Unable to spawn shell process: %s', e) | 342 | logger.error('Unable to spawn shell process: %s', e) |
| 340 | sys.exit(1) | 343 | sys.exit(1) |
| 341 | except Exception as e: | 344 | except Exception: |
| 342 | logger.error('Unexpected exception: %s', e) | 345 | logger.exception('Unexpected exception') |
| 343 | sys.exit(1) | 346 | sys.exit(1) |
| 344 | 347 | ||
| 345 | 348 | ||
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py index aab42a0..870a4be 100755..100644 --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py | |||
| @@ -1,5 +1,5 @@ | |||
| 1 | # etoolkit | 1 | # etoolkit |
| 2 | # Copyright (C) 2021-2022 Simeon Simeonov | 2 | # Copyright (C) 2021-2024 Simeon Simeonov |
| 3 | 3 | ||
| 4 | # This program is free software: you can redistribute it and/or modify | 4 | # This program is free software: you can redistribute it and/or modify |
| 5 | # it under the terms of the GNU General Public License as published by | 5 | # it under the terms of the GNU General Public License as published by |
| @@ -14,6 +14,7 @@ | |||
| 14 | # You should have received a copy of the GNU General Public License | 14 | # You should have received a copy of the GNU General Public License |
| 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 16 | """The main module of the etoolkit package""" | 16 | """The main module of the etoolkit package""" |
| 17 | |||
| 17 | import base64 | 18 | import base64 |
| 18 | import getpass | 19 | import getpass |
| 19 | import hashlib | 20 | import hashlib |
| @@ -23,6 +24,9 @@ from cryptography.exceptions import InvalidTag | |||
| 23 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM | 24 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM |
| 24 | 25 | ||
| 25 | 26 | ||
| 27 | MIN_ENCRYPTED_VALUE_LENGTH = 32 | ||
| 28 | |||
| 29 | |||
| 26 | class EtoolkitInstanceError(Exception): | 30 | class EtoolkitInstanceError(Exception): |
| 27 | """EtoolkitInstanceError - Generic exceptions related to instances""" | 31 | """EtoolkitInstanceError - Generic exceptions related to instances""" |
| 28 | 32 | ||
| @@ -175,24 +179,33 @@ class EtoolkitInstance: | |||
| 175 | :rtype: str | 179 | :rtype: str |
| 176 | """ | 180 | """ |
| 177 | # check for supported versions | 181 | # check for supported versions |
| 178 | if not edata.startswith('enc-val$1$'): | 182 | if not edata.startswith(('enc-val$1$', 'enc-val$2$')): |
| 179 | raise EtoolkitInstanceError( | 183 | raise EtoolkitInstanceError( |
| 180 | f'Unsupported encryption format: {edata}' | 184 | f'Unsupported encryption format: {edata}' |
| 181 | ) | 185 | ) |
| 182 | try: | 186 | try: |
| 183 | salt, data = [base64.b64decode(t) for t in edata[10:].split('$')] | 187 | salt, data = (base64.b64decode(t) for t in edata[10:].split('$')) |
| 184 | nonce = salt[:12] | 188 | nonce = salt[:12] |
| 185 | aesgcm = AESGCM( | 189 | aesgcm = AESGCM( |
| 186 | hashlib.scrypt( | 190 | hashlib.scrypt( |
| 187 | password.encode('utf-8'), | 191 | password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 |
| 188 | salt=salt, | ||
| 189 | n=2**14, | ||
| 190 | r=8, | ||
| 191 | p=1, | ||
| 192 | dklen=32, | ||
| 193 | ) | 192 | ) |
| 194 | ) | 193 | ) |
| 195 | return aesgcm.decrypt(nonce, data, salt).decode() | 194 | |
| 195 | # decrypt | ||
| 196 | data = aesgcm.decrypt(nonce, data, salt) | ||
| 197 | |||
| 198 | if edata.startswith('enc-val$2$'): | ||
| 199 | # exclusively for the v2 data format: | ||
| 200 | # padding_length_bytes(2 bytes) data padding (between 0 and 32) | ||
| 201 | |||
| 202 | # extract padding_length_bytes | ||
| 203 | if data[:2] == b'--': | ||
| 204 | data = data[2:] | ||
| 205 | else: | ||
| 206 | data = data[2 : -int(data[:2].decode())] | ||
| 207 | |||
| 208 | return data.decode() | ||
| 196 | except InvalidTag as e: | 209 | except InvalidTag as e: |
| 197 | raise EtoolkitInstanceError( | 210 | raise EtoolkitInstanceError( |
| 198 | f'Invalid tag when decrypting: {edata}' | 211 | f'Invalid tag when decrypting: {edata}' |
| @@ -207,6 +220,8 @@ class EtoolkitInstance: | |||
| 207 | """ | 220 | """ |
| 208 | Encrypts `data` using `password`. | 221 | Encrypts `data` using `password`. |
| 209 | 222 | ||
| 223 | Version 2 of the etoolkit encryption format | ||
| 224 | |||
| 210 | The output string is in the following format: | 225 | The output string is in the following format: |
| 211 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` | 226 | enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` |
| 212 | 227 | ||
| @@ -219,21 +234,37 @@ class EtoolkitInstance: | |||
| 219 | :return: The output string | 234 | :return: The output string |
| 220 | :rtype: str | 235 | :rtype: str |
| 221 | """ | 236 | """ |
| 222 | salt = os.urandom(32) | 237 | data_bytes = data.encode() |
| 223 | aesgcm = AESGCM( | 238 | if len(data_bytes) < MIN_ENCRYPTED_VALUE_LENGTH: |
| 224 | hashlib.scrypt( | 239 | padding_length = MIN_ENCRYPTED_VALUE_LENGTH - len(data_bytes) |
| 225 | password.encode('utf-8'), | 240 | rnd_bytes = os.urandom(32 + padding_length) |
| 226 | salt=salt, | 241 | salt = rnd_bytes[:32] |
| 227 | n=2**14, | 242 | aesgcm = AESGCM( |
| 228 | r=8, | 243 | hashlib.scrypt( |
| 229 | p=1, | 244 | password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 |
| 230 | dklen=32, | 245 | ) |
| 246 | ) | ||
| 247 | nonce = rnd_bytes[:12] | ||
| 248 | padding_bytes = rnd_bytes[32:] | ||
| 249 | # padding_length_bytes is always 2 bytes | ||
| 250 | padding_length_bytes = f'{padding_length:02d}'.encode() | ||
| 251 | edata = aesgcm.encrypt( | ||
| 252 | nonce, padding_length_bytes + data_bytes + padding_bytes, salt | ||
| 253 | ) | ||
| 254 | else: | ||
| 255 | salt = os.urandom(32) | ||
| 256 | aesgcm = AESGCM( | ||
| 257 | hashlib.scrypt( | ||
| 258 | password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 | ||
| 259 | ) | ||
| 260 | ) | ||
| 261 | nonce = salt[:12] | ||
| 262 | padding_length_bytes = b'--' # no padding used 2 bytes "sign" | ||
| 263 | edata = aesgcm.encrypt( | ||
| 264 | nonce, padding_length_bytes + data_bytes, salt | ||
| 231 | ) | 265 | ) |
| 232 | ) | ||
| 233 | nonce = salt[:12] | ||
| 234 | edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt) | ||
| 235 | return ( | 266 | return ( |
| 236 | f'enc-val$1${base64.b64encode(salt).decode()}$' | 267 | f'enc-val$2${base64.b64encode(salt).decode()}$' |
| 237 | f'{base64.b64encode(edata).decode()}' | 268 | f'{base64.b64encode(edata).decode()}' |
| 238 | ) | 269 | ) |
| 239 | 270 | ||
| @@ -252,7 +283,7 @@ class EtoolkitInstance: | |||
| 252 | :rtype: str | 283 | :rtype: str |
| 253 | """ | 284 | """ |
| 254 | hash_algo = 'sha256' | 285 | hash_algo = 'sha256' |
| 255 | iterations = 100000 | 286 | iterations = 500000 |
| 256 | salt = os.urandom(32) | 287 | salt = os.urandom(32) |
| 257 | key = hashlib.pbkdf2_hmac( | 288 | key = hashlib.pbkdf2_hmac( |
| 258 | hash_algo, password.encode('utf-8'), salt, iterations | 289 | hash_algo, password.encode('utf-8'), salt, iterations |
| @@ -339,7 +370,6 @@ class EtoolkitInstance: | |||
| 339 | macros = { | 370 | macros = { |
| 340 | '%h': os.path.expanduser('~'), | 371 | '%h': os.path.expanduser('~'), |
| 341 | '%i': self.name, | 372 | '%i': self.name, |
| 342 | # '%f': self.get_full_name(), | ||
| 343 | '%u': getpass.getuser(), | 373 | '%u': getpass.getuser(), |
| 344 | } | 374 | } |
| 345 | new_env = {} | 375 | new_env = {} |
