envstack 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envstack/__init__.py +40 -0
- envstack/cli.py +478 -0
- envstack/config.py +108 -0
- envstack/encrypt.py +361 -0
- envstack/env.py +997 -0
- envstack/envshell.py +143 -0
- envstack/exceptions.py +82 -0
- envstack/logger.py +61 -0
- envstack/node.py +410 -0
- envstack/path.py +448 -0
- envstack/util.py +800 -0
- envstack-1.0.3.dist-info/LICENSE +12 -0
- envstack-1.0.3.dist-info/METADATA +177 -0
- envstack-1.0.3.dist-info/RECORD +17 -0
- envstack-1.0.3.dist-info/WHEEL +5 -0
- envstack-1.0.3.dist-info/entry_points.txt +3 -0
- envstack-1.0.3.dist-info/top_level.txt +1 -0
envstack/encrypt.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Contains cryptography classes and functions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import binascii
|
|
38
|
+
import os
|
|
39
|
+
import secrets
|
|
40
|
+
import warnings
|
|
41
|
+
from base64 import b64decode, b64encode
|
|
42
|
+
from functools import wraps
|
|
43
|
+
|
|
44
|
+
from envstack.logger import log
|
|
45
|
+
|
|
46
|
+
# cryptography and _rust dependency may not be available everywhere
|
|
47
|
+
# ImportError: DLL load failed while importing _rust: Module not found.
|
|
48
|
+
CRYPTOGRAPHY_AVAILABLE = False
|
|
49
|
+
Fernet = None
|
|
50
|
+
InvalidToken = type("InvalidToken", (Exception,), {})
|
|
51
|
+
InvalidTag = type("InvalidTag", (Exception,), {})
|
|
52
|
+
padding = None
|
|
53
|
+
Cipher = None
|
|
54
|
+
algorithms = None
|
|
55
|
+
modes = None
|
|
56
|
+
try:
|
|
57
|
+
# cryptography emits a Python 3.8 deprecation warning during import.
|
|
58
|
+
with warnings.catch_warnings():
|
|
59
|
+
warnings.filterwarnings(
|
|
60
|
+
"ignore",
|
|
61
|
+
message=r"Python 3\.8 is no longer supported by the Python core team.*",
|
|
62
|
+
category=DeprecationWarning,
|
|
63
|
+
module=r"cryptography(\..*)?$",
|
|
64
|
+
)
|
|
65
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
66
|
+
from cryptography.exceptions import InvalidTag
|
|
67
|
+
from cryptography.hazmat.primitives import padding
|
|
68
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
69
|
+
|
|
70
|
+
CRYPTOGRAPHY_AVAILABLE = True
|
|
71
|
+
except ImportError as err:
|
|
72
|
+
log.debug("cryptography module not available: %s", err)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def require_cryptography(func):
|
|
76
|
+
"""Guard crypto-backed functions when cryptography is unavailable."""
|
|
77
|
+
|
|
78
|
+
@wraps(func)
|
|
79
|
+
def wrapper(*args, **kwargs):
|
|
80
|
+
if not CRYPTOGRAPHY_AVAILABLE:
|
|
81
|
+
raise RuntimeError("cryptography support is not available")
|
|
82
|
+
return func(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Base64Encryptor(object):
|
|
88
|
+
"""Encrypt and decrypt secrets using base64 encoding."""
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
super().__init__()
|
|
92
|
+
|
|
93
|
+
def encrypt(self, data: str):
|
|
94
|
+
"""Encrypt a secret using base64 encoding."""
|
|
95
|
+
return b64encode(str(data).encode()).decode()
|
|
96
|
+
|
|
97
|
+
def decrypt(self, data: str):
|
|
98
|
+
"""Decrypt a secret using base64 encoding."""
|
|
99
|
+
try:
|
|
100
|
+
return b64decode(data).decode()
|
|
101
|
+
except UnicodeDecodeError as e:
|
|
102
|
+
log.debug("invalid base64 encoding: %s", e)
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class FernetEncryptor(object):
|
|
107
|
+
"""Encrypt and decrypt secrets using Fernet symmetric encryption."""
|
|
108
|
+
|
|
109
|
+
KEY_VAR_NAME = "ENVSTACK_FERNET_KEY"
|
|
110
|
+
|
|
111
|
+
def __init__(self, key: str = None, env: dict = os.environ):
|
|
112
|
+
if key and Fernet:
|
|
113
|
+
self.key = Fernet(key)
|
|
114
|
+
else:
|
|
115
|
+
self.key = self.get_key(env)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
@require_cryptography
|
|
119
|
+
def generate_key(csl):
|
|
120
|
+
"""Generate a new 256-bit encryption key."""
|
|
121
|
+
if Fernet:
|
|
122
|
+
key = Fernet.generate_key()
|
|
123
|
+
return key.decode()
|
|
124
|
+
else:
|
|
125
|
+
log.debug("Fernet encryption not available")
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
def get_key(self, env: dict = os.environ):
|
|
129
|
+
"""Load the encryption key from the environment `env`.
|
|
130
|
+
|
|
131
|
+
:param env: The environment containing the key.
|
|
132
|
+
:return: encryption key.
|
|
133
|
+
"""
|
|
134
|
+
key = env.get(self.KEY_VAR_NAME)
|
|
135
|
+
if key and Fernet:
|
|
136
|
+
return Fernet(key)
|
|
137
|
+
return key
|
|
138
|
+
|
|
139
|
+
@require_cryptography
|
|
140
|
+
def encrypt(self, data: str):
|
|
141
|
+
"""Encrypt a secret using Fernet.
|
|
142
|
+
|
|
143
|
+
:param data: The secret to encrypt.
|
|
144
|
+
:return: Base64-encoded binary blob.
|
|
145
|
+
"""
|
|
146
|
+
results = ""
|
|
147
|
+
if not data:
|
|
148
|
+
return results
|
|
149
|
+
try:
|
|
150
|
+
results = self.key.encrypt(str(data).encode()).decode()
|
|
151
|
+
except InvalidToken:
|
|
152
|
+
log.error("invalid encryption key")
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
log.error("invalid value: %s", e)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
log.error("unhandled error: %s", e)
|
|
157
|
+
return results
|
|
158
|
+
|
|
159
|
+
@require_cryptography
|
|
160
|
+
def decrypt(self, data: str):
|
|
161
|
+
"""Decrypt a secret using Fernet.
|
|
162
|
+
|
|
163
|
+
:param data: Base64-encoded binary blob.
|
|
164
|
+
:return: The decrypted secret.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
return self.key.decrypt(str(data).encode()).decode()
|
|
168
|
+
except InvalidToken:
|
|
169
|
+
log.debug("invalid encryption key")
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
log.debug("invalid value: %s", e)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
log.debug("unhandled error: %s", e)
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class AESGCMEncryptor(object):
|
|
178
|
+
"""Encrypt and decrypt secrets using AES-GCM symmetric encryption."""
|
|
179
|
+
|
|
180
|
+
KEY_VAR_NAME = "ENVSTACK_SYMMETRIC_KEY"
|
|
181
|
+
|
|
182
|
+
def __init__(self, key: str = None, env: dict = os.environ):
|
|
183
|
+
if key:
|
|
184
|
+
self.key = b64decode(key)
|
|
185
|
+
else:
|
|
186
|
+
self.key = self.get_key(env)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
@require_cryptography
|
|
190
|
+
def generate_key(csl):
|
|
191
|
+
"""Generate a new 256-bit encryption key."""
|
|
192
|
+
key = secrets.token_bytes(32)
|
|
193
|
+
return b64encode(key).decode()
|
|
194
|
+
|
|
195
|
+
def get_key(self, env: dict = os.environ):
|
|
196
|
+
"""Load the encryption key from the environment `env`.
|
|
197
|
+
|
|
198
|
+
:param env: The environment containing the key.
|
|
199
|
+
"""
|
|
200
|
+
key = env.get(self.KEY_VAR_NAME)
|
|
201
|
+
if key:
|
|
202
|
+
try:
|
|
203
|
+
return b64decode(key)
|
|
204
|
+
except binascii.Error as e:
|
|
205
|
+
raise ValueError("invalid base64 encoding: %s" % e)
|
|
206
|
+
return key
|
|
207
|
+
|
|
208
|
+
@require_cryptography
|
|
209
|
+
def encrypt_data(self, secret: str):
|
|
210
|
+
"""Encrypt a secret using AES-GCM.
|
|
211
|
+
|
|
212
|
+
:param secret: The secret to encrypt.
|
|
213
|
+
:param key: The encryption key.
|
|
214
|
+
:return: Dictionary containing nonce, ciphertext, and tag.
|
|
215
|
+
"""
|
|
216
|
+
nonce = os.urandom(12) # GCM requires a 12-byte nonce
|
|
217
|
+
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce))
|
|
218
|
+
encryptor = cipher.encryptor()
|
|
219
|
+
padded_data = pad_data(secret)
|
|
220
|
+
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
|
221
|
+
return {
|
|
222
|
+
"nonce": b64encode(nonce).decode(),
|
|
223
|
+
"ciphertext": b64encode(ciphertext).decode(),
|
|
224
|
+
"tag": b64encode(encryptor.tag).decode(),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@require_cryptography
|
|
228
|
+
def decrypt_data(self, encrypted_data: dict):
|
|
229
|
+
"""Decrypt a secret using AES-GCM.
|
|
230
|
+
|
|
231
|
+
:param encrypted_data: Dictionary containing nonce, ciphertext, and tag.
|
|
232
|
+
:param key: The encryption key.
|
|
233
|
+
:returns: The decrypted secret.
|
|
234
|
+
"""
|
|
235
|
+
nonce = b64decode(encrypted_data["nonce"])
|
|
236
|
+
ciphertext = b64decode(encrypted_data["ciphertext"])
|
|
237
|
+
tag = b64decode(encrypted_data["tag"])
|
|
238
|
+
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce, tag))
|
|
239
|
+
decryptor = cipher.decryptor()
|
|
240
|
+
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
|
|
241
|
+
return unpad_data(padded_data)
|
|
242
|
+
|
|
243
|
+
def encrypt(self, data: str):
|
|
244
|
+
"""Convenience function to encrypt a secret using AES-GCM.
|
|
245
|
+
|
|
246
|
+
:param data: The data to encrypt.
|
|
247
|
+
:returns: Base64-encoded binary blob.
|
|
248
|
+
"""
|
|
249
|
+
results = ""
|
|
250
|
+
if not data:
|
|
251
|
+
return results
|
|
252
|
+
try:
|
|
253
|
+
encrypted_data = self.encrypt_data(data)
|
|
254
|
+
results = compact_store(encrypted_data)
|
|
255
|
+
except binascii.Error as e:
|
|
256
|
+
log.error("invalid base64 encoding: %s", e)
|
|
257
|
+
except InvalidTag:
|
|
258
|
+
log.error("invalid encryption key")
|
|
259
|
+
except ValueError as e:
|
|
260
|
+
log.error("invalid value: %s", e)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
log.error("unhandled error: %s", e)
|
|
263
|
+
return results
|
|
264
|
+
|
|
265
|
+
def decrypt(self, data: str):
|
|
266
|
+
"""Convenience function to decrypt a secret using AES-GCM.
|
|
267
|
+
|
|
268
|
+
:param data: Base64-encoded binary blob.
|
|
269
|
+
:returns: The decrypted secret.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
encrypted_data = compact_load(data)
|
|
273
|
+
decrypted = self.decrypt_data(encrypted_data)
|
|
274
|
+
return decrypted.decode()
|
|
275
|
+
except binascii.Error as e:
|
|
276
|
+
log.debug("invalid base64 encoding: %s", e)
|
|
277
|
+
except InvalidTag:
|
|
278
|
+
log.debug("invalid encryption key")
|
|
279
|
+
except ValueError as e:
|
|
280
|
+
log.debug("invalid value: %s", e)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
log.debug("unhandled error: %s", e)
|
|
283
|
+
return data
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@require_cryptography
|
|
287
|
+
def pad_data(data: str):
|
|
288
|
+
"""Pad data to be block-aligned for AES encryption.
|
|
289
|
+
|
|
290
|
+
:param data: The data to pad.
|
|
291
|
+
:returns: The padded data.
|
|
292
|
+
"""
|
|
293
|
+
padder = padding.PKCS7(algorithms.AES.block_size).padder()
|
|
294
|
+
return padder.update(str(data).encode()) + padder.finalize()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@require_cryptography
|
|
298
|
+
def unpad_data(data: dict):
|
|
299
|
+
"""Unpad data after decryption.
|
|
300
|
+
|
|
301
|
+
:param data: The data to unpad.
|
|
302
|
+
:returns: The unpadded data.
|
|
303
|
+
"""
|
|
304
|
+
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
|
305
|
+
return unpadder.update(data) + unpadder.finalize()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def compact_store(encrypted_data: dict):
|
|
309
|
+
"""Combine nonce, ciphertext, and tag into a single binary blob.
|
|
310
|
+
|
|
311
|
+
:param encrypted_data: Dictionary containing nonce, ciphertext, and tag.
|
|
312
|
+
:returns: Base64-encoded binary blob.
|
|
313
|
+
"""
|
|
314
|
+
# convert all parts to binary (bytes) if they are in base64
|
|
315
|
+
nonce = base64.b64decode(encrypted_data["nonce"])
|
|
316
|
+
ciphertext = base64.b64decode(encrypted_data["ciphertext"])
|
|
317
|
+
tag = base64.b64decode(encrypted_data["tag"])
|
|
318
|
+
|
|
319
|
+
# concatenate and encode to base64 for storage
|
|
320
|
+
return base64.b64encode(nonce + ciphertext + tag).decode()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def compact_load(compact_data: str):
|
|
324
|
+
"""Separate nonce, ciphertext, and tag from a compact binary blob.
|
|
325
|
+
|
|
326
|
+
:param compact_data: Base64-encoded binary blob.
|
|
327
|
+
:returns: Dictionary containing nonce, ciphertext, and tag.
|
|
328
|
+
"""
|
|
329
|
+
# decode the base64-encoded string
|
|
330
|
+
binary_data = base64.b64decode(compact_data)
|
|
331
|
+
|
|
332
|
+
# extract parts:
|
|
333
|
+
# - nonce (12 bytes)
|
|
334
|
+
# - ciphertext (remaining bytes - 16 bytes)
|
|
335
|
+
# - tag (16 bytes)
|
|
336
|
+
nonce = binary_data[:12]
|
|
337
|
+
tag = binary_data[-16:]
|
|
338
|
+
ciphertext = binary_data[12:-16]
|
|
339
|
+
|
|
340
|
+
# return the parts in Base64 format for compatibility with the encrypt/
|
|
341
|
+
# decrypt functions
|
|
342
|
+
return {
|
|
343
|
+
"nonce": base64.b64encode(nonce).decode(),
|
|
344
|
+
"ciphertext": base64.b64encode(ciphertext).decode(),
|
|
345
|
+
"tag": base64.b64encode(tag).decode(),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def generate_keys():
|
|
350
|
+
"""Generate encryption keys for Fernet and AES-GCM.
|
|
351
|
+
|
|
352
|
+
:returns: Dictionary containing Fernet and AES-GCM keys.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
symmetric_key = AESGCMEncryptor.generate_key()
|
|
356
|
+
fernet_key = FernetEncryptor.generate_key()
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
AESGCMEncryptor.KEY_VAR_NAME: symmetric_key,
|
|
360
|
+
FernetEncryptor.KEY_VAR_NAME: fernet_key,
|
|
361
|
+
}
|