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