summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSimeon Simeonov2022-03-15 05:54:56 +0100
committerSimeon Simeonov2022-03-15 05:54:56 +0100
commit44eaafe50370c076269e80d9252bca969814d238 (patch)
treef6c3964c92b428c9f68449f828610cf4add2a58e /src
parent907f80e1889781a69edded00126b3e78ddeb7b8e (diff)
Restructure the project in ordere to replace distutils with setup tools
Diffstat (limited to 'src')
-rw-r--r--src/etoolkit/__init__.py34
-rw-r--r--src/etoolkit/__main__.py341
-rwxr-xr-xsrc/etoolkit/etoolkit.py406
3 files changed, 781 insertions, 0 deletions
diff --git a/src/etoolkit/__init__.py b/src/etoolkit/__init__.py
new file mode 100644
index 0000000..e2925ef
--- /dev/null
+++ b/src/etoolkit/__init__.py
@@ -0,0 +1,34 @@
1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov
3
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
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13
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/>.
16"""A simple toolkit for setting environment variables in a flexible way"""
17from .etoolkit import EtoolkitInstance, EtoolkitInstanceError
18
19__author__ = 'Simeon Simeonov'
20__version__ = '1.1.0'
21__license__ = 'GPL3'
22
23
24def int_or_str(value):
25 """Returns int value of value when possible"""
26 try:
27 return int(value)
28 except ValueError:
29 return value
30
31
32VERSION = tuple(map(int_or_str, __version__.split('.')))
33
34__all__ = ['EtoolkitInstance', 'EtoolkitInstanceError']
diff --git a/src/etoolkit/__main__.py b/src/etoolkit/__main__.py
new file mode 100644
index 0000000..603bb2e
--- /dev/null
+++ b/src/etoolkit/__main__.py
@@ -0,0 +1,341 @@
1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov
3
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
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13
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/>.
16"""
17CLI entry point for the etoolkit package
18
19Examples:
20python -m etoolkit -h
21
22python -m etoolkit -p
23"""
24import argparse
25import errno
26import getpass
27import io
28import json
29import logging
30import os
31import sys
32
33import etoolkit
34
35DEFAULT_LOG_FORMAT = "%(levelname)s: %(message)s"
36DEFAULT_LOG_LEVEL = logging.WARNING
37
38logger = logging.getLogger(__name__)
39
40
41def decrypt_value(args: argparse.Namespace, config: dict):
42 """
43 Interactive function for decrypting value(s)
44
45 Prompts for master key password and then prompts for a value to decrypt
46
47 The decrypted value is printed to stdout
48
49 :param args: The arguments sent by the caller
50 :type args: arparse.Namespace
51
52 :param config: The config dict sent by the caller
53 :type config: dict
54 """
55 password_hash = None
56 pipe_input = None
57 if not os.isatty(sys.stdin.fileno()):
58 pipe_input = sys.stdin.read().strip()
59 if 'general' in config:
60 password_hash = config['general'].get('MASTER_PASSWORD_HASH')
61
62 if (
63 args.master_password_prompt
64 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
65 ):
66 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
67 password_hash, False
68 )
69 else:
70 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
71
72 if pipe_input:
73 # the input came from stdin. No need to prompt
74 print(
75 'Decrypted value: '
76 f'{etoolkit.EtoolkitInstance.decrypt(password, pipe_input)}'
77 )
78 return
79 while True:
80 try:
81 value = input('Value: ')
82 print(
83 'Decrypted value: '
84 f'{etoolkit.EtoolkitInstance.decrypt(password, value)}'
85 )
86 if not args.multiple_values:
87 break
88 except KeyboardInterrupt:
89 print(os.linesep)
90 break
91 return
92
93
94def encrypt_value(args: argparse.Namespace, config: dict):
95 """
96 Interactive function for encrypting value(s)
97
98 Prompts for master key password and then prompts for a value to encrypt
99
100 The encrypted value is printed to stdout
101
102 :param args: The arguments sent by the caller
103 :type args: arparse.Namespace
104
105 :param config: The config dict sent by the caller
106 :type config: dict
107 """
108 password_hash = None
109 pipe_input = None
110 if not os.isatty(sys.stdin.fileno()):
111 pipe_input = sys.stdin.read().strip()
112 if 'general' in config:
113 password_hash = config['general'].get('MASTER_PASSWORD_HASH')
114
115 if (
116 args.master_password_prompt
117 or os.environ.get('ETOOLKIT_MASTER_PASSWORD') is None
118 ):
119 password = etoolkit.EtoolkitInstance.confirm_password_prompt(
120 password_hash
121 )
122 else:
123 password = os.environ.get('ETOOLKIT_MASTER_PASSWORD')
124
125 if pipe_input:
126 # the input came from stdin. No need to prompt
127 print(
128 'Encrypted value: '
129 f'{etoolkit.EtoolkitInstance.encrypt(password, pipe_input)}'
130 )
131 return
132 while True:
133 try:
134 if args.echo:
135 value = input('Value: ')
136 else:
137 value = getpass.getpass('Value: ')
138 print(
139 'Encrypted value: '
140 f'{etoolkit.EtoolkitInstance.encrypt(password, value)}'
141 )
142 if not args.multiple_values:
143 break
144 except KeyboardInterrupt:
145 print(os.linesep)
146 break
147 return
148
149
150def main(inargs=None):
151 """main entry point"""
152 parser = argparse.ArgumentParser(
153 prog=__package__,
154 epilog=(
155 f'%(prog)s {etoolkit.__version__} by Simeon Simeonov '
156 '(sgs @ LiberaChat)'
157 ),
158 description='The following options are available',
159 )
160 group = parser.add_mutually_exclusive_group(required=True)
161 group.add_argument(
162 'instance',
163 metavar='<instance>',
164 nargs='?',
165 type=str,
166 help='The instance to be loaded',
167 )
168 group.add_argument(
169 '-d',
170 '--decrypt-value',
171 dest='decrypt_value',
172 action='store_true',
173 required=False,
174 help=(
175 'Prompt for master password & value to decrypt, '
176 'display the decrypted value and exit'
177 ),
178 )
179 group.add_argument(
180 '-e',
181 '--encrypt-value',
182 dest='encrypt_value',
183 action='store_true',
184 required=False,
185 help=(
186 'Prompt for master password & value to encrypt, '
187 'display the encrypted value and exit'
188 ),
189 )
190 group.add_argument(
191 '-l',
192 '--list',
193 dest='list',
194 action='store_true',
195 required=False,
196 help='List all defined instances',
197 )
198 group.add_argument(
199 '-p',
200 '--generate-master-password-hash',
201 dest='password_hash',
202 action='store_true',
203 required=False,
204 help='Prompt for master password, display the generated hash and exit',
205 )
206 parser.add_argument(
207 '-c',
208 '--config-file',
209 metavar='<path>',
210 type=str,
211 default=os.path.expanduser(
212 os.environ.get('ETOOLKIT_CONFIG', '~/.etoolkit.json')
213 ),
214 dest='config_file',
215 help='JSON config file (default: ~/.etoolkit.json)',
216 )
217 parser.add_argument(
218 '-E',
219 '--echo',
220 dest='echo',
221 action='store_true',
222 help='Display the value to be encrypted (used together with -e)',
223 )
224 parser.add_argument(
225 '-m',
226 '--multiple-values',
227 dest='multiple_values',
228 action='store_true',
229 help=(
230 'Prompt for more than one value when '
231 'encrypting / decrypting until terminated '
232 '(Ctrl+C) (used together with -d / -e)'
233 ),
234 )
235 parser.add_argument(
236 '-P',
237 '--master-password-prompt',
238 dest='master_password_prompt',
239 action='store_true',
240 help=(
241 'Force prompt for the master password even if the env. variable '
242 '"ETOOLKIT_MASTER_PASSWORD" is set'
243 ),
244 )
245 parser.add_argument(
246 '-q',
247 '--no-output',
248 dest='dump_output',
249 action='store_false',
250 default=True,
251 help='Do not print environment variables to stdout',
252 )
253 parser.add_argument(
254 '-s',
255 '--spawn',
256 metavar='<path>',
257 type=str,
258 default='',
259 dest='spawn',
260 help='Spawn another process than $SHELL',
261 )
262 parser.add_argument(
263 '-v',
264 '--version',
265 action='version',
266 version=f'%(prog)s {etoolkit.__version__}',
267 help='Display program-version and exit',
268 )
269 args = parser.parse_args(inargs)
270 try:
271 with io.open(args.config_file, 'r', encoding='utf-8') as fp:
272 config_dict = json.load(fp)
273 except FileNotFoundError as e:
274 # do not raise exception if config-file is missing for:
275 # - decrypting value
276 # - encrypting value
277 # - password hash generation
278 if args.password_hash or args.decrypt_value or args.encrypt_value:
279 logger.warning(
280 "Configuration file %s is missing, although not required "
281 "by the provided parameters",
282 args.config_file,
283 )
284 config_dict = {}
285 else:
286 logger.error("Configuration file %s is missing", args.config_file)
287 raise SystemExit(errno.EIO) from e
288 except Exception as e:
289 logger.error("Unable to parse %r: %s", args.config_file, e)
290 raise SystemExit(errno.EIO) from e
291 try:
292 if args.decrypt_value:
293 decrypt_value(args, config_dict)
294 sys.exit(0)
295 if args.encrypt_value:
296 encrypt_value(args, config_dict)
297 sys.exit(0)
298 if args.password_hash:
299 master_password = (
300 etoolkit.EtoolkitInstance.confirm_password_prompt()
301 )
302 phash = etoolkit.EtoolkitInstance.get_new_password_hash(
303 master_password
304 )
305 print(f'Master password hash: {phash}')
306 sys.exit(0)
307 if args.list:
308 for instance_name in sorted(
309 config_dict.get('instances', {}).keys()
310 ):
311 print(instance_name)
312 sys.exit(0)
313
314 inst = etoolkit.EtoolkitInstance(args.instance, config_dict)
315 inst.prompt_func = etoolkit.EtoolkitInstance.confirm_password_prompt
316 env = inst.get_environ()
317
318 if args.dump_output:
319 inst.dump_env(env)
320
321 os.environ.update(env)
322
323 if args.spawn:
324 os.system(args.spawn)
325 else:
326 os.system(os.getenv('SHELL', 'bash'))
327 except KeyboardInterrupt:
328 logger.debug('KeyboardInterrupt')
329 print(os.linesep)
330 sys.exit(0)
331 except etoolkit.EtoolkitInstanceError as e:
332 logger.error('EtoolkitInstanceError: %s', e)
333 sys.exit(1)
334 except Exception as e:
335 logger.error('Unexpected exception: %s', e)
336 sys.exit(1)
337
338
339if __name__ == '__main__':
340 logging.basicConfig(level=DEFAULT_LOG_LEVEL, format=DEFAULT_LOG_FORMAT)
341 main()
diff --git a/src/etoolkit/etoolkit.py b/src/etoolkit/etoolkit.py
new file mode 100755
index 0000000..d8221a5
--- /dev/null
+++ b/src/etoolkit/etoolkit.py
@@ -0,0 +1,406 @@
1# etoolkit
2# Copyright (C) 2021-2022 Simeon Simeonov
3
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
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13
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/>.
16"""The main module of the etoolkit package"""
17import base64
18import getpass
19import hashlib
20import os
21
22from cryptography.exceptions import InvalidTag
23from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24
25
26class EtoolkitInstanceError(Exception):
27 """EtoolkitInstanceError - Generic exceptions related to instances"""
28
29
30class EtoolkitInstance:
31 """A basic class representing a single instance"""
32
33 def __init__(self, name: str, data: dict):
34 """
35 :param name: Instance name
36 :type name: str
37
38 :param data: .etoolkit.json alike dict
39 :type data: dict
40 """
41 self._name = name
42 self._parent = None
43 self._raw_env_variables = {}
44 self._sensitive_env_variables = []
45 self._master_password = None
46 self._master_password_hash = None
47 self._prompt_func = None # function to use when prompting for input
48 try:
49 inst_data = data['instances'][name]
50 except KeyError as e:
51 raise EtoolkitInstanceError(f'Unknown instance "{name}"') from e
52 if inst_data.get('ETOOLKIT_PARENT'):
53 self._parent = EtoolkitInstance(inst_data['ETOOLKIT_PARENT'], data)
54 self._raw_env_variables.update(self._parent.raw_env_variables)
55 self._sensitive_env_variables.extend(
56 self._parent.sensitive_env_variables
57 )
58 if inst_data.get('ETOOLKIT_SENSITIVE'):
59 if not isinstance(inst_data['ETOOLKIT_SENSITIVE'], list):
60 raise EtoolkitInstanceError(
61 '"ETOOLKIT_SENSITIVE" must be a list'
62 )
63 self._sensitive_env_variables.extend(
64 inst_data['ETOOLKIT_SENSITIVE']
65 )
66 self._raw_env_variables.update(inst_data)
67 # remove non env. variable data
68 self._raw_env_variables.pop('ETOOLKIT_PARENT', None)
69 self._raw_env_variables.pop('ETOOLKIT_SENSITIVE', None)
70 if 'general' in data and 'MASTER_PASSWORD_HASH' in data['general']:
71 self._master_password_hash = data['general'][
72 'MASTER_PASSWORD_HASH'
73 ]
74
75 @property
76 def master_password(self) -> str:
77 """master_password-property"""
78 return self._master_password
79
80 @master_password.setter
81 def master_password(self, value):
82 """master_password-property setter"""
83 self._master_password = value
84
85 @property
86 def master_password_hash(self) -> str:
87 """master_password_hash-property"""
88 return self._master_password_hash
89
90 @master_password_hash.setter
91 def master_password_hash(self, value):
92 """master_password_hash-property setter"""
93 self._master_password_hash = value
94
95 @property
96 def name(self) -> str:
97 """name-property"""
98 return self._name
99
100 @property
101 def prompt_func(self):
102 """prompt_func-property"""
103 return self._prompt_func
104
105 @prompt_func.setter
106 def prompt_func(self, value):
107 """prompt_func-property setter"""
108 self._prompt_func = value
109
110 @property
111 def raw_env_variables(self) -> dict:
112 """raw_env_variables-property"""
113 return self._raw_env_variables
114
115 @property
116 def sensitive_env_variables(self) -> list:
117 """sensitive_env_variables-property"""
118 return self._sensitive_env_variables
119
120 @staticmethod
121 def confirm_password_prompt(
122 password_hash: str = None, confirm: bool = True
123 ) -> str:
124 """
125 Prompts for master password and then for confirmation if `confirm` True
126
127 :param password_hash: Hash to compare with instead of confirm
128 :type password_hash: str
129
130 :param confirm: Confirm the password (and see if there is a match)
131 :type confirm: bool
132
133 :return: Password provided by the user
134 :rtype: str
135 """
136 try:
137 while True:
138 pass1 = getpass.getpass('Type master password: ')
139 if password_hash:
140 if EtoolkitInstance.password_matches(pass1, password_hash):
141 return pass1
142 print('Wrong password')
143 continue
144 if confirm:
145 pass2 = getpass.getpass('Confirm master password: ')
146 if not pass1 or pass1 != pass2:
147 print('The passwords are either empty or do not match')
148 continue
149 return pass1.strip()
150 except Exception as e:
151 raise EtoolkitInstanceError('Prompt error') from e
152
153 @staticmethod
154 def decrypt(password: str, edata: str) -> str:
155 """
156 Decrypts `edata` using `password`.
157
158 `edata` is in the following format:
159 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
160
161 :param password: The password to generate the key with
162 :type password: str
163
164 :param edata: The data to be decrypted
165 :type edata: str
166
167 :return: The output string / decrypted data
168 :rtype: str
169 """
170 # check for supported versions
171 if not edata.startswith('enc-val$1$'):
172 raise EtoolkitInstanceError(
173 f'Unsupported encryption format: {edata}'
174 )
175 try:
176 salt, data = [base64.b64decode(t) for t in edata[10:].split('$')]
177 nonce = salt[:12]
178 aesgcm = AESGCM(
179 hashlib.scrypt(
180 password.encode('utf-8'),
181 salt=salt,
182 n=2**14,
183 r=8,
184 p=1,
185 dklen=32,
186 )
187 )
188 return aesgcm.decrypt(nonce, data, salt).decode()
189 except InvalidTag as e:
190 raise EtoolkitInstanceError(
191 f'Invalid tag when decrypting: {edata}'
192 ) from e
193 except Exception as e:
194 raise EtoolkitInstanceError(
195 f'Error when decrypting: {edata}'
196 ) from e
197
198 @staticmethod
199 def encrypt(password: str, data: str) -> str:
200 """
201 Encrypts `data` using `password`.
202
203 The output string is in the following format:
204 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
205
206 :param password: The password to generate the key with
207 :type password: str
208
209 :param data: The data to be encrypted
210 :type data: str
211
212 :return: The output string
213 :rtype: str
214 """
215 salt = os.urandom(32)
216 aesgcm = AESGCM(
217 hashlib.scrypt(
218 password.encode('utf-8'),
219 salt=salt,
220 n=2**14,
221 r=8,
222 p=1,
223 dklen=32,
224 )
225 )
226 nonce = salt[:12]
227 edata = aesgcm.encrypt(nonce, data.encode('utf-8'), salt)
228 return (
229 f'enc-val$1${base64.b64encode(salt).decode()}$'
230 f'{base64.b64encode(edata).decode()}'
231 )
232
233 @staticmethod
234 def get_new_password_hash(password: str) -> str:
235 """
236 Returns a complete password hash based on `password`
237
238 This password hash is *not* used as a key when encrypting / decrypting
239 but only for optional check if a correct master password is provided.
240
241 :param password: The plaintext password
242 :type password: str
243
244 :return: The hashed version of `password`
245 :rtype: str
246 """
247 hash_algo = 'sha256'
248 iterations = 100000
249 salt = os.urandom(32)
250 key = hashlib.pbkdf2_hmac(
251 hash_algo, password.encode('utf-8'), salt, iterations
252 )
253 return (
254 f'pbkdf2_{hash_algo}${iterations}$'
255 f'{base64.b64encode(salt).decode()}$'
256 f'{base64.b64encode(key).decode()}'
257 )
258
259 @staticmethod
260 def parse_value(value, macros: dict):
261 """
262 Returns the value with all macros replaced by their values
263
264 If `value` is not of type 'str' simply return `value`
265
266 :param value: A simple value
267 :type value: object
268
269 :param macros: Macros mapping
270 :type macros: dict
271
272 :return: New value with all macros replaced by their values
273 :rtype: object
274 """
275 if not isinstance(value, str):
276 return value
277 for key, val in macros.items():
278 value = value.replace(key, val)
279 return value
280
281 @staticmethod
282 def password_matches(password: str, password_hash: str) -> bool:
283 """
284 Checks `password` agains s stored password hash
285
286 Hash format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64
287
288 :param password: password
289 :type password: str
290
291 :param password_hash: The pbkdf2_hmac password hash
292 :type password_hash: str
293
294 :return: True if the password matches or password_hash is None,
295 :rtype: bool
296 """
297 if password_hash is None:
298 return True
299 # format: pbkdf2_hashalgo$ietarations$salt-base64$key-base64
300 try:
301 tokens = password_hash.split('$')
302 key = hashlib.pbkdf2_hmac(
303 tokens[0].split('_')[1],
304 password.encode('utf-8'),
305 base64.b64decode(tokens[2]),
306 int(tokens[1]),
307 )
308 return key == base64.b64decode(tokens[3])
309 except Exception:
310 return False
311
312 def dump_env(self, env: dict):
313 """
314 Prints an environment dict to stdout.
315
316 :param env: The environment dict
317 :type env: dict
318 """
319 for key, value in env.items():
320 if key in self._sensitive_env_variables:
321 print(f'{key}: ***')
322 continue
323 print(f'{key}: {value}')
324
325 def get_environ(self) -> dict:
326 """
327 Generates a new environ dict
328
329 :return: New environment dict with all macros replaced by their values
330 :rtype: dict
331 """
332 macros = {
333 '%h': os.path.expanduser('~'),
334 '%i': self.name,
335 # '%f': self.get_full_name(),
336 '%u': getpass.getuser(),
337 }
338 new_env = {}
339 for key, value in sorted(
340 self._raw_env_variables.items(), key=lambda x: x[0]
341 ):
342 if not value:
343 # perhaps unset instead of skipping?
344 continue
345 if isinstance(value, str) and value.startswith('enc-val$1$'):
346 value = self._decrypt_value(value)
347 if isinstance(value, str) and value.endswith(':'):
348 # if 'value' ends with ':', append the existing value of
349 # os.environ[key] after the value of 'value'
350 new_env[key] = self.parse_value(
351 value, macros
352 ) + os.environ.get(key, '')
353 elif isinstance(value, str) and value.startswith(':'):
354 # if 'value' starts with ':', append after the existing value
355 # of os.environ[key]
356 new_env[key] = os.environ.get(key, '') + self.parse_value(
357 value, macros
358 )
359 else:
360 # completely overwrite the existing value of os.environ[key]
361 new_env[key] = self.parse_value(value, macros)
362 return new_env
363
364 def get_full_name(self, delimiter: str = '') -> str:
365 """
366 Returns the entire instance inheritence path separated by `delimiter`
367
368 The format is:
369 `grandparent-name`<delimiter>`parent-name`<delimiter>`instance-name`
370
371 :param delimiter: Delimiter to separate parent instance names by
372 :type delimiter: str
373
374 :return: Path in case this instance has parent, instance.name otherwise
375 :rtype: str
376 """
377 if self._parent is None:
378 return self.name
379 return self._parent.get_full_name(delimiter) + delimiter + self.name
380
381 def _decrypt_value(self, evalue: str) -> str:
382 """
383 Decrypts an encrypted value using the master password
384
385 The method prompts for the master password when it encounters its
386 first encrypted value since the creation of its EtoolkitInstance
387 object
388
389 `evalue` is in the following format:
390 enc-val$`version-num`$`bas64-salt`$`base64-encrypted_data`
391
392 :param evalue: Encrypted value to be decrypted
393 :type evalue: str
394
395 :return: Decrypted value
396 :rtype: str
397 """
398 if self._master_password is None:
399 if self._prompt_func is None:
400 raise EtoolkitInstanceError(
401 'Neither password or prompt function set'
402 )
403 self._master_password = self._prompt_func(
404 self._master_password_hash, confirm=False
405 )
406 return EtoolkitInstance.decrypt(self._master_password, evalue)