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/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
+ }