summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/etoolkit/__init__.py5
-rw-r--r--src/etoolkit/__main__.py23
-rw-r--r--[-rwxr-xr-x]src/etoolkit/etoolkit.py80
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
17from .etoolkit import EtoolkitInstance, EtoolkitInstanceError 18from .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"""
17CLI entry point for the etoolkit package 17CLI entry point for the etoolkit package
18 18
19Examples: 19Examples
20--------
20python -m etoolkit -h 21python -m etoolkit -h
21 22
22python -m etoolkit -p 23python -m etoolkit -p
24
23""" 25"""
26
24import argparse 27import argparse
25import errno 28import errno
26import getpass 29import getpass
@@ -33,7 +36,7 @@ import sys
33 36
34import etoolkit 37import etoolkit
35 38
36DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s" 39DEFAULT_LOG_FORMAT = '%(levelname)s: %(message)s'
37DEFAULT_LOG_LEVEL = logging.WARNING 40DEFAULT_LOG_LEVEL = logging.WARNING
38 41
39logger = logging.getLogger(__name__) 42logger = 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
17import base64 18import base64
18import getpass 19import getpass
19import hashlib 20import hashlib
@@ -23,6 +24,9 @@ from cryptography.exceptions import InvalidTag
23from cryptography.hazmat.primitives.ciphers.aead import AESGCM 24from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24 25
25 26
27MIN_ENCRYPTED_VALUE_LENGTH = 32
28
29
26class EtoolkitInstanceError(Exception): 30class 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 = {}