From c31fa58c85e866b3a5ab04882c7aff655f0b5477 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Tue, 30 Apr 2024 11:39:20 +0200 Subject: Implement etoolkit encryption protocol v2 --- src/etoolkit/__init__.py | 5 +-- src/etoolkit/__main__.py | 23 ++++++++------ src/etoolkit/etoolkit.py | 80 +++++++++++++++++++++++++++++++++--------------- 3 files changed, 71 insertions(+), 37 deletions(-) mode change 100755 => 100644 src/etoolkit/etoolkit.py (limited to 'src') 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 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,10 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """A simple toolkit for setting environment variables in a flexible way""" + from .etoolkit import EtoolkitInstance, EtoolkitInstanceError __author__ = 'Simeon Simeonov' -__version__ = '1.2.0' +__version__ = '1.3.0' __license__ = 'GPL3' 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 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,11 +16,14 @@ """ CLI entry point for the etoolkit package -Examples: +Examples +-------- python -m etoolkit -h python -m etoolkit -p + """ + import argparse import errno import getpass @@ -33,7 +36,7 @@ import sys import etoolkit -DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" +DEFAULT_LOG_FORMAT = '%(levelname)s: %(message)s' DEFAULT_LOG_LEVEL = logging.WARNING logger = logging.getLogger(__name__) @@ -269,7 +272,7 @@ def main(inargs=None): ) args = parser.parse_args(inargs) try: - with io.open(args.config_file, 'r', encoding='utf-8') as fp: + with io.open(args.config_file, encoding='utf-8') as fp: config_dict = json.load(fp) except FileNotFoundError as e: # do not raise exception if config-file is missing for: @@ -278,16 +281,16 @@ def main(inargs=None): # - password hash generation if args.password_hash or args.decrypt_value or args.encrypt_value: logger.warning( - "Configuration file %s is missing, although not required " - "by the provided parameters", + 'Configuration file %s is missing, although not required ' + 'by the provided parameters', args.config_file, ) config_dict = {} else: - logger.error("Configuration file %s is missing", args.config_file) + logger.error('Configuration file %s is missing', args.config_file) raise SystemExit(errno.EIO) from e except Exception as e: - logger.error("Unable to parse %r: %s", args.config_file, e) + logger.exception('Unable to parse %r', args.config_file) raise SystemExit(errno.EIO) from e try: if args.decrypt_value: @@ -338,8 +341,8 @@ def main(inargs=None): except subprocess.CalledProcessError as e: logger.error('Unable to spawn shell process: %s', e) sys.exit(1) - except Exception as e: - logger.error('Unexpected exception: %s', e) + except Exception: + logger.exception('Unexpected exception') sys.exit(1) diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py old mode 100755 new mode 100644 index aab42a0..870a4be --- a/src/etoolkit/etoolkit.py +++ b/src/etoolkit/etoolkit.py @@ -1,5 +1,5 @@ # etoolkit -# Copyright (C) 2021-2022 Simeon Simeonov +# Copyright (C) 2021-2024 Simeon Simeonov # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The main module of the etoolkit package""" + import base64 import getpass import hashlib @@ -23,6 +24,9 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM +MIN_ENCRYPTED_VALUE_LENGTH = 32 + + class EtoolkitInstanceError(Exception): """EtoolkitInstanceError - Generic exceptions related to instances""" @@ -175,24 +179,33 @@ class EtoolkitInstance: :rtype: str """ # check for supported versions - if not edata.startswith('enc-val$1$'): + if not edata.startswith(('enc-val$1$', 'enc-val$2$')): raise EtoolkitInstanceError( f'Unsupported encryption format: {edata}' ) try: - salt, data = [base64.b64decode(t) for t in edata[10:].split('$')] + salt, data = (base64.b64decode(t) for t in edata[10:].split('$')) nonce = salt[:12] aesgcm = AESGCM( hashlib.scrypt( - password.encode('utf-8'), - salt=salt, - n=2**14, - r=8, - p=1, - dklen=32, + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 ) ) - return aesgcm.decrypt(nonce, data, salt).decode() + + # decrypt + data = aesgcm.decrypt(nonce, data, salt) + + if edata.startswith('enc-val$2$'): + # exclusively for the v2 data format: + # padding_length_bytes(2 bytes) data padding (between 0 and 32) + + # extract padding_length_bytes + if data[:2] == b'--': + data = data[2:] + else: + data = data[2 : -int(data[:2].decode())] + + return data.decode() except InvalidTag as e: raise EtoolkitInstanceError( f'Invalid tag when decrypting: {edata}' @@ -207,6 +220,8 @@ class EtoolkitInstance: """ Encrypts `data` using `password`. + Version 2 of the etoolkit encryption format + The output string is in the following format: enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data` @@ -219,21 +234,37 @@ class EtoolkitInstance: :return: The output string :rtype: str """ - salt = os.urandom(32) - aesgcm = AESGCM( - hashlib.scrypt( - password.encode('utf-8'), - salt=salt, - n=2**14, - r=8, - p=1, - dklen=32, + data_bytes = data.encode() + if len(data_bytes) < MIN_ENCRYPTED_VALUE_LENGTH: + padding_length = MIN_ENCRYPTED_VALUE_LENGTH - len(data_bytes) + rnd_bytes = os.urandom(32 + padding_length) + salt = rnd_bytes[:32] + aesgcm = AESGCM( + hashlib.scrypt( + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 + ) + ) + nonce = rnd_bytes[:12] + padding_bytes = rnd_bytes[32:] + # padding_length_bytes is always 2 bytes + padding_length_bytes = f'{padding_length:02d}'.encode() + edata = aesgcm.encrypt( + nonce, padding_length_bytes + data_bytes + padding_bytes, salt + ) + else: + salt = os.urandom(32) + aesgcm = AESGCM( + hashlib.scrypt( + password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32 + ) + ) + nonce = salt[:12] + padding_length_bytes = b'--' # no padding used 2 bytes "sign" + edata = aesgcm.encrypt( + nonce, padding_length_bytes + data_bytes, salt ) - ) - nonce = salt[:12] - edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt) return ( - f'enc-val$1${base64.b64encode(salt).decode()}$' + f'enc-val$2${base64.b64encode(salt).decode()}$' f'{base64.b64encode(edata).decode()}' ) @@ -252,7 +283,7 @@ class EtoolkitInstance: :rtype: str """ hash_algo = 'sha256' - iterations = 100000 + iterations = 500000 salt = os.urandom(32) key = hashlib.pbkdf2_hmac( hash_algo, password.encode('utf-8'), salt, iterations @@ -339,7 +370,6 @@ class EtoolkitInstance: macros = { '%h': os.path.expanduser('~'), '%i': self.name, - # '%f': self.get_full_name(), '%u': getpass.getuser(), } new_env = {} -- cgit v1.3