ghostbytes 1.0.0__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.
@@ -0,0 +1,360 @@
1
+ """
2
+ primitives.py
3
+
4
+ This modules provides helpers for:
5
+
6
+ * AES-GCM encryption / decryption
7
+ * RSA-OAEP encryption / decryption
8
+ * Argon2id KDF
9
+ * RSA key generation / verification
10
+ * Key-format, key-type detection (for non-pycryptodome)
11
+
12
+ The ``ML-KEM`` implementations / functions are located in kyber.py
13
+ The ``extended_oaep`` implementations / functions are located in oaep_extension.py
14
+ """
15
+
16
+ from Crypto.Cipher import AES as _aes
17
+ from Crypto.Cipher import PKCS1_OAEP as _oaep
18
+ from Crypto.PublicKey import RSA as _rsa
19
+
20
+ from argon2.low_level import hash_secret_raw, Type
21
+
22
+ from cryptography.hazmat.primitives import serialization
23
+ from cryptography.hazmat.primitives.asymmetric import mlkem, rsa
24
+
25
+ from ghostbytes.crypto.config import RSA_KEY_OUT_FORMAT, CryptoConfig
26
+ from ghostbytes.error import aes_envelope_extraction_failed, data_integrity_check_failed, \
27
+ decryption_key_incorrect, envelope_too_small, invalid_argument, invalid_configuration_type, \
28
+ invalid_keyfile, keyfile_cannot_crypt, keyfile_passphrase_incorrect, rsa_message_too_big, \
29
+ rsa_public_exponent_not_prime, store_iv_choice_mismatch, unsupported_keyfile
30
+
31
+
32
+ def aes_encrypt(config, key, plaintext):
33
+ """
34
+ Encrypts plaintext using AES-GCM
35
+
36
+ Args:
37
+ config: encryption configuration in ``CryptoConfig``
38
+ key: AES key as bytes, must be 16, 24, or 32 bytes
39
+ plaintext: data to encrypt as bytes
40
+
41
+ Returns:
42
+ AES envelop of ciphertext and authentication tag as bytes.
43
+
44
+ Raises:
45
+ invalid_configuration_type: when config is not a ``CryptoConfig`` instance
46
+ invalid_argument: if ``plaintext`` or ``key`` is not bytes, or if key has an
47
+ invalid length
48
+ store_iv_choice_mismatch: if ``config.store_iv`` is invalid.
49
+ """
50
+ _require_config(config)
51
+ if not isinstance(plaintext, bytes) or not isinstance(key, bytes):
52
+ raise invalid_argument("AES plaintext and key", "must be bytes")
53
+ if len(key) not in (16, 24, 32):
54
+ raise invalid_argument("AES key", "must be 16, 24, or 32 bytes")
55
+ encryptor = _aes.new(
56
+ key=key,
57
+ mode=_aes.MODE_GCM,
58
+ mac_len=config.mac_len,
59
+ use_aesni=True,
60
+ )
61
+
62
+ ciphertext, digest = encryptor.encrypt_and_digest(plaintext)
63
+
64
+ if config.store_iv == 'append':
65
+ ciphertext += encryptor.nonce + digest
66
+ elif config.store_iv == 'prepend':
67
+ ciphertext = encryptor.nonce + digest + ciphertext
68
+ else:
69
+ raise store_iv_choice_mismatch()
70
+
71
+ return ciphertext
72
+
73
+
74
+ def aes_decrypt(config, key, ciphertext):
75
+ """
76
+ Decrypt and authenticate an AES-GCM envelop
77
+
78
+ The function extracts the AES-GCM nonce and authenticate tag from the
79
+ ciphertext and verifies it with the decrypted plaintext.
80
+
81
+ Args:
82
+ config: encryption configuration in ``CryptoConfig``
83
+ key: AES key as bytes, must be 16, 24, or 32 bytes
84
+ ciphertext: data to decrypt as bytes
85
+
86
+ Returns:
87
+ The decrypted text as plaintext
88
+
89
+ Raises:
90
+ invalid_configuration_type: when config is not a ``CryptoConfig`` instance
91
+ invalid_argument: if ``plaintext`` or ``key`` is not bytes, or if key has an
92
+ invalid length
93
+ envelop_too_small: if the size of the envelop is too small to contain a
94
+ complete ML-KEM ciphertext
95
+ aes_envelope_extraction_failed: if the authentication tag cannot be
96
+ extracted correctly.
97
+ decrypt_key_incorrect: if decryption fails
98
+ data_integrity_check_failed: if authentication fails
99
+ store_iv_choice_mismatch: if ``config.store_iv`` is invalid.
100
+ """
101
+ _require_config(config)
102
+ envelop_len = 16 + config.mac_len # 16 bytes for nonce length
103
+ if not isinstance(ciphertext, bytes) or not isinstance(key, bytes):
104
+ raise invalid_argument("AES ciphertext and key", "must be bytes")
105
+ if len(key) not in (16, 24, 32):
106
+ raise invalid_argument("AES key", "must be 16, 24, or 32 bytes")
107
+ if len(ciphertext) < envelop_len:
108
+ raise envelope_too_small()
109
+
110
+ if config.store_iv == 'append':
111
+ nonce = ciphertext[len(ciphertext) -
112
+ envelop_len:len(ciphertext) -
113
+ envelop_len +
114
+ 16]
115
+ mac = ciphertext[len(ciphertext) - envelop_len + 16:]
116
+ ciphertext = ciphertext[:len(ciphertext) - envelop_len]
117
+ elif config.store_iv == 'prepend':
118
+ nonce = ciphertext[:16]
119
+ mac = ciphertext[16:16 + config.mac_len]
120
+ ciphertext = ciphertext[16 + config.mac_len:]
121
+ else:
122
+ raise store_iv_choice_mismatch()
123
+
124
+ if len(mac) != config.mac_len:
125
+ raise aes_envelope_extraction_failed()
126
+
127
+ decryptor = _aes.new(
128
+ key=key,
129
+ mode=_aes.MODE_GCM,
130
+ mac_len=config.mac_len,
131
+ use_aesni=True,
132
+ nonce=nonce
133
+ )
134
+
135
+ try:
136
+ plaintext = decryptor.decrypt(ciphertext)
137
+ except ValueError as e:
138
+ raise decryption_key_incorrect() from e
139
+
140
+ try:
141
+ decryptor.verify(mac)
142
+ except ValueError as e:
143
+ raise data_integrity_check_failed() from e
144
+
145
+ return plaintext
146
+
147
+
148
+ def rsa_oaep_encrypt(config, key, plaintext):
149
+ """
150
+ Encrypt a message using RSA-OAEP.
151
+
152
+ Args:
153
+ config: encryption configuration in ``CryptoConfig``
154
+ key: RSA public key.
155
+ plaintext: data to encrypt as bytes
156
+
157
+ Returns:
158
+ The RSA-OAEP ciphertext as bytes
159
+
160
+ Raises:
161
+ invalid_configuration_types: if ``config`` is invalid
162
+ keyfile_cannot_crypt: if ``key`` cannot encrypt
163
+ rsa_message_too_big: if the plaintext is too large for RSA-OAEP
164
+ """
165
+ _require_config(config)
166
+ if not key.can_encrypt():
167
+ raise keyfile_cannot_crypt("encrypt")
168
+
169
+ oaep = _oaep.new(
170
+ key,
171
+ config.hash_func
172
+ )
173
+
174
+ try:
175
+ return oaep.encrypt(plaintext)
176
+ except ValueError as e:
177
+ raise rsa_message_too_big() from e
178
+
179
+
180
+ def rsa_oaep_decrypt(config, key, ciphertext):
181
+ """
182
+ Decrypt a message using RSA-OAEP
183
+
184
+ Args:
185
+ config: encryption configuration in ``CryptoConfig``
186
+ ciphertext: data to decrypt as bytes
187
+ key: RSA private key.
188
+
189
+ Returns:
190
+ The decrypted plaintext as bytes
191
+
192
+ Raises:
193
+ invalid_configuration_types: if ``config`` is invalid
194
+ keyfile_cannot_crypt: if ``key`` cannot decrypt (``key`` is not private key)
195
+ decryption_key_incorrect: if RSA-OAEP decryption fails
196
+ """
197
+ _require_config(config)
198
+ if not key.has_private():
199
+ raise keyfile_cannot_crypt("decrypt")
200
+
201
+ oaep = _oaep.new(
202
+ key,
203
+ config.hash_func
204
+ )
205
+
206
+ try:
207
+ return oaep.decrypt(ciphertext)
208
+ except ValueError as e:
209
+ raise decryption_key_incorrect() from e
210
+
211
+
212
+ def derive_key(config, secret):
213
+ """
214
+ Derive a 256-bit key from a secret using Argon2id memory-hard
215
+ key derivation function (KDF)
216
+
217
+ Args:
218
+ config: key derivation configuration in ``CryptoConfig``
219
+ secret: Non-empty secret material as bytes
220
+
221
+ Returns:
222
+ A 32-bytes derived key.
223
+
224
+ Raises:
225
+ invalid_configuration_type: if ``config`` is invalid
226
+ invalid_argument: if ``key`` is not non-empty bytes
227
+ """
228
+ _require_config(config)
229
+ if not isinstance(secret, bytes) or not secret:
230
+ raise invalid_argument("KDF secret", "must be non-empty bytes")
231
+ return hash_secret_raw(
232
+ secret=secret,
233
+ salt=config.kdf_salt,
234
+ time_cost=config.kdf_time_cost,
235
+ memory_cost=config.kdf_memory_cost,
236
+ parallelism=config.kdf_parallelism,
237
+ hash_len=32,
238
+ type=Type.ID
239
+ )
240
+
241
+
242
+ def genrsa(length, exp, passphrase, out_format=RSA_KEY_OUT_FORMAT[0]):
243
+ """
244
+ Generate and serialize an RSA key pair.
245
+
246
+ Args:
247
+ key_size: RSA modulus size in bits. Must be at least 1024
248
+ exponent: RSA public exponent. Must be an odd prime greater than 2
249
+ passphrase: Passphrase used to encrypt the private key
250
+ out_format: Output format supported by PyCryptodome, such as ``"PEM"``
251
+
252
+ Returns:
253
+ A tuple ``(public_key, private_key)``.
254
+ Each key is encoded into bytes.
255
+
256
+ Raises:
257
+ invalid_argument: if a parameter is invalid (rsa key size < 1024 bits? RSA exponent
258
+ too small? RSA key encoding not supported?)
259
+ rsa_public_exponent_not_prime: if ``exponent`` is not prime.
260
+ """
261
+ if not isinstance(length, int) or length < 1024:
262
+ raise invalid_argument("RSA key size",
263
+ "must be an integer of at least 1024 bits")
264
+ if not isinstance(exp, int) or exp < 3:
265
+ raise invalid_argument(
266
+ "RSA public exponent",
267
+ "must be an integer greater than 2")
268
+ if out_format not in RSA_KEY_OUT_FORMAT:
269
+ raise invalid_argument("RSA key encoding", "is not supported")
270
+ for i in range(2, int(exp**0.5) + 1):
271
+ if exp % i == 0:
272
+ raise rsa_public_exponent_not_prime()
273
+
274
+ key = _rsa.generate(length, None, exp)
275
+ public = key.public_key()
276
+
277
+ public_key = public.export_key(out_format)
278
+ private_key = key.export_key(out_format, passphrase)
279
+
280
+ return public_key, private_key
281
+
282
+
283
+ def verify_rsa(public_key, private_key, passphrase):
284
+ """
285
+ Verifies that an RSA public key matches a private key
286
+
287
+ Args:
288
+ public_key: Serialised RSA public key
289
+ private_key: Serialised RSA private key
290
+ passphrase: passphrase for the private key
291
+
292
+ Returns:
293
+ ``True`` if the keys contain the same RSA modulus; otherwise ``False``
294
+
295
+ Raises:
296
+ keyfile_passphrase_incorrect: if the passphrase is invalid
297
+ invalid_keyfile: if either key cannot be imported.
298
+ """
299
+ try:
300
+ priv = _rsa.import_key(private_key, passphrase)
301
+ except (ValueError, TypeError, IndexError) as e:
302
+ if passphrase is not None:
303
+ raise keyfile_passphrase_incorrect() from e
304
+ raise invalid_keyfile() from e
305
+ if not priv.has_private():
306
+ return False
307
+ try:
308
+ pub = _rsa.import_key(public_key, passphrase)
309
+ except (ValueError, TypeError, IndexError) as e:
310
+ if passphrase is not None:
311
+ raise keyfile_passphrase_incorrect() from e
312
+ raise invalid_keyfile() from e
313
+ return priv.n == pub.n
314
+
315
+
316
+ def detect_key_format(key_bytes: bytes) -> str:
317
+ """Detect if the serialised key data is PEM or DER encoded through inspecting the headers"""
318
+ if key_bytes.startswith(b"-----BEGIN"):
319
+ return "PEM"
320
+
321
+ if key_bytes[0] == 0x30:
322
+ if len(key_bytes) > 1 and (key_bytes[1] & 0x80 or key_bytes[1] < 128):
323
+ return "DER"
324
+
325
+ raise invalid_keyfile()
326
+
327
+
328
+ def keytype(key_bytes, passphrase=None):
329
+ """Determine whether serialised key data contains an RSA or ML-KEM key"""
330
+ try:
331
+ if detect_key_format(key_bytes) == "PEM":
332
+ if b"PRIVATE KEY" in key_bytes:
333
+ key_obj = serialization.load_pem_private_key(
334
+ key_bytes, password=passphrase)
335
+ else:
336
+ key_obj = serialization.load_pem_public_key(key_bytes)
337
+ else:
338
+ try:
339
+ key_obj = serialization.load_der_private_key(
340
+ key_bytes, password=passphrase)
341
+ except Exception:
342
+ key_obj = serialization.load_der_public_key(key_bytes)
343
+ except Exception as e:
344
+ raise invalid_keyfile() from e
345
+
346
+ if isinstance(key_obj, (rsa.RSAPrivateKey, rsa.RSAPublicKey)):
347
+ return "RSA"
348
+ if isinstance(
349
+ key_obj,
350
+ (mlkem.MLKEM768PrivateKey,
351
+ mlkem.MLKEM768PublicKey,
352
+ mlkem.MLKEM1024PrivateKey,
353
+ mlkem.MLKEM1024PublicKey)):
354
+ return "ML-KEM"
355
+ raise unsupported_keyfile()
356
+
357
+
358
+ def _require_config(config):
359
+ if not isinstance(config, CryptoConfig):
360
+ raise invalid_configuration_type()
ghostbytes/error.py ADDED
@@ -0,0 +1,177 @@
1
+ """Exception types and error factories used throughout Ghostbytes."""
2
+
3
+ # Error factories intentionally use compact one-line definitions and retain
4
+ # the public lambda-based API for compatibility.
5
+ # pylint: disable=missing-function-docstring,multiple-statements,unnecessary-lambda-assignment
6
+
7
+ from datetime import datetime
8
+
9
+ INTERNAL_ERROR = "INTERNAL_ERROR"
10
+ USAGE_ERROR = "USAGE_ERROR"
11
+ INCORRECT_KEY_ERROR = "INCORRECT_KEY"
12
+
13
+
14
+ class GhostBytesError(Exception):
15
+ """Base exception carrying a user-facing message and optional heading."""
16
+
17
+ def __init__(self, message, heading=None):
18
+ super().__init__(message)
19
+ self.heading = heading
20
+ self.message = message
21
+
22
+ def __str__(self):
23
+ now = datetime.now().replace(microsecond=0).isoformat()
24
+ heading = self.__class__.__name__.removesuffix("Error") + " Error"
25
+ if self.heading is not None:
26
+ heading += f" ({self.heading})"
27
+ return f"[{now}] {heading}: {self.message}"
28
+
29
+
30
+ class CryptoError(GhostBytesError):
31
+ """Error raised for cryptographic operation failures."""
32
+
33
+
34
+ class GeneralError(GhostBytesError):
35
+ """Error raised for invalid usage or general operation failures."""
36
+
37
+
38
+ # Cryptographic operation errors.
39
+ def decryption_key_incorrect(): return CryptoError("Decryption key incorrect!")
40
+
41
+
42
+ def data_integrity_check_failed(): return CryptoError(
43
+ "Data integrity check failed. The data may have been modified or corrupted.")
44
+
45
+
46
+ def keyfile_passphrase_incorrect(): return CryptoError(
47
+ "Passphrase for RSA private key incorrect!")
48
+
49
+
50
+ def keyfile_cannot_crypt(operation): return CryptoError(
51
+ f"Provided RSA key cannot {operation}")
52
+
53
+
54
+ def invalid_keyfile(): return CryptoError(
55
+ "Invalid keyfile. Use a public/private key pair generated by Ghostbytes or an equivalent tool.")
56
+
57
+
58
+ def unsupported_keyfile(): return CryptoError(
59
+ "Unsupported keyfile. Use a keyfile generated by a supported algorithm.")
60
+
61
+
62
+ def envelope_too_small(): return CryptoError(
63
+ "Failed to extract encryption envelope. The file is too small to contain encrypted data.",
64
+ USAGE_ERROR)
65
+
66
+
67
+ def rsa_message_too_big(): return CryptoError(
68
+ "Failed to encrypt RSA message. The message exceeds the maximum size supported by the key.")
69
+
70
+
71
+ def algorithm_not_supported(): return CryptoError(
72
+ "Algorithm not supported", INTERNAL_ERROR)
73
+
74
+
75
+ def store_iv_choice_mismatch(): return CryptoError(
76
+ "STORE_IV must be either `append` or `prepend`", USAGE_ERROR)
77
+ def aes_envelope_extraction_failed(): return CryptoError(
78
+ "AES envelope extraction failed", INTERNAL_ERROR)
79
+
80
+
81
+ def hash_not_supported(): return CryptoError("Hash not supported", USAGE_ERROR)
82
+
83
+
84
+ def rsa_public_exponent_not_prime(): return CryptoError(
85
+ "Public exponent is not a prime number", USAGE_ERROR)
86
+
87
+
88
+ def unsupported_mlkem_key(): return CryptoError(
89
+ "The selected key is not a supported ML-KEM-768 or ML-KEM-1024 key.",
90
+ USAGE_ERROR)
91
+
92
+
93
+ def text_not_in_bytes(): return TypeError(
94
+ "Ciphertext / plaintext must be in Bytes form")
95
+
96
+ # Input and configuration errors.
97
+
98
+
99
+ def parameter_not_exist(name): return GeneralError(
100
+ f"Parameter name `{name}` must exist.", USAGE_ERROR)
101
+
102
+
103
+ invalid_argument = lambda name, detail="is invalid": GeneralError(
104
+ f"Parameter `{name}` {detail}.", USAGE_ERROR)
105
+
106
+
107
+ def invalid_configuration(detail): return GeneralError(
108
+ f"Invalid Ghostbytes configuration: {detail}", USAGE_ERROR)
109
+
110
+
111
+ def invalid_configuration_type(): return GeneralError(
112
+ "Configuration must be an instance of `CryptoConfig`.", USAGE_ERROR)
113
+
114
+
115
+ def invalid_mode(mode): return GeneralError(
116
+ f"Unsupported batch mode `{mode}`. Use `encrypt` or `decrypt`.", USAGE_ERROR)
117
+
118
+
119
+ def invalid_random_length(): return GeneralError(
120
+ "Random byte length must be a non-negative integer.", USAGE_ERROR)
121
+
122
+
123
+ def invalid_repeat(): return GeneralError(
124
+ "Shred repeat count must be at least 1.", USAGE_ERROR)
125
+
126
+
127
+ def invalid_pattern(): return GeneralError(
128
+ "Overwrite pattern cannot be empty.", USAGE_ERROR)
129
+
130
+
131
+ def key_encoding_not_found(): return GeneralError(
132
+ "Key encoding not found", USAGE_ERROR)
133
+
134
+ # File and system errors.
135
+
136
+
137
+ def io_error(operation): return GeneralError(
138
+ f"Unable to {operation}.", INTERNAL_ERROR)
139
+
140
+
141
+ def temporary_write_failed(): return GeneralError(
142
+ "Unable to write temporary wipe data.", INTERNAL_ERROR)
143
+
144
+
145
+ def file_not_found(): return GeneralError(
146
+ "Filename specified cannot be found", USAGE_ERROR)
147
+
148
+
149
+ def not_a_file(): return GeneralError(
150
+ "The selected filename is not a file.", USAGE_ERROR)
151
+
152
+
153
+ def shred_option_not_supported(): return GeneralError(
154
+ "Shred option not supported", USAGE_ERROR)
155
+
156
+
157
+ def chunk_size_invalid(): return GeneralError(
158
+ "Chunk size in shred functions must be greater than zero", USAGE_ERROR)
159
+
160
+
161
+ def partition_not_found(): return GeneralError(
162
+ "Disk partition not found!", INTERNAL_ERROR)
163
+
164
+
165
+ def temporary_filename_unavailable(): return GeneralError(
166
+ "Failed to generate a random temporary file for shred operations", INTERNAL_ERROR)
167
+
168
+ # Randomness and key-management errors.
169
+
170
+
171
+ def random_function_not_supported(): return CryptoError(
172
+ "Random function not supported", USAGE_ERROR)
173
+
174
+
175
+ def unix_only_function(function): return GeneralError(
176
+ f"{function} is only available on Unix-like systems.", USAGE_ERROR
177
+ )