transcrypto 1.0.3__py3-none-any.whl → 1.1.1__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.
transcrypto/aes.py ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright 2025 Daniel Balparda (balparda@github.com) - Apache-2.0 license
4
+ #
5
+ """Balparda's TransCrypto Advanced Encryption Standard (AES) library.
6
+
7
+ <https://en.wikipedia.org/wiki/Advanced_Encryption_Standard>
8
+
9
+ <https://cryptography.io/en/latest/>
10
+
11
+ The Advanced Encryption Standard (AES), also known by its original name Rijndael
12
+ is a specification for the encryption of electronic data established by the
13
+ U.S. National Institute of Standards and Technology (NIST) in 2001.
14
+
15
+ We don't want to re-implement AES here, we will provide for good crypto
16
+ wrappers, consistent with the transcrypto style.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ # import datetime
23
+ # import pdb
24
+ from typing import Self
25
+
26
+ from cryptography.hazmat.primitives import ciphers
27
+ from cryptography.hazmat.primitives.ciphers import algorithms, modes
28
+ from cryptography.hazmat.primitives import hashes as hazmat_hashes
29
+ from cryptography.hazmat.primitives.kdf import pbkdf2 as hazmat_pbkdf2
30
+ from cryptography import exceptions as crypt_exceptions
31
+
32
+ from . import base
33
+
34
+ __author__ = 'balparda@github.com'
35
+ __version__: str = base.__version__ # version comes from base!
36
+ __version_tuple__: tuple[int, ...] = base.__version_tuple__
37
+
38
+
39
+ # these fixed salt/iterations are for password->key generation only; NEVER use them to
40
+ # build a database of passwords because it would not be safe; NEVER change them or the
41
+ # keys will change and previous databases/encryptions will become inconsistent/unreadable!
42
+ _PASSWORD_SALT_256: bytes = base.HexToBytes(
43
+ '63b56fe9260ed3ff752a86a3414e4358e4d8e3e31b9dbc16e11ec19809e2f3c0') # fixed random salt: do NOT ever change!
44
+ _PASSWORD_ITERATIONS = 2025103 # fixed iterations, purposefully huge: do NOT ever change!
45
+ assert base.BytesToEncoded(_PASSWORD_SALT_256) == 'Y7Vv6SYO0_91KoajQU5DWOTY4-MbnbwW4R7BmAni88A=', 'should never happen: constant'
46
+ assert _PASSWORD_ITERATIONS == (6075308 + 1) // 3, 'should never happen: constant'
47
+
48
+
49
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
50
+ class AESKey(base.CryptoKey, base.SymmetricCrypto):
51
+ """Advanced Encryption Standard (AES) 256 bits key (32 bytes).
52
+
53
+ No measures are taken here to prevent timing attacks.
54
+
55
+ Attributes:
56
+ key256 (bytes): AES 256 bits key (32 bytes), so length is always 32
57
+ """
58
+
59
+ key256: bytes
60
+
61
+ def __post_init__(self) -> None:
62
+ """Check data.
63
+
64
+ Raises:
65
+ InputError: invalid inputs
66
+ """
67
+ super(AESKey, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
68
+ if len(self.key256) != 32:
69
+ raise base.InputError(f'invalid key256: {self}')
70
+
71
+ def __str__(self) -> str:
72
+ """Safe (no secrets) string representation of the AESKey.
73
+
74
+ Returns:
75
+ string representation of AESKey without leaking secrets
76
+ """
77
+ return f'AESKey(key256={base.ObfuscateSecret(self.key256)})'
78
+
79
+ @classmethod
80
+ def FromStaticPassword(cls, str_password: str, /) -> Self:
81
+ """Derive crypto key using string password.
82
+
83
+ This is, purposefully, a very costly operation that should be cheap to execute once
84
+ after the user typed a password, but costly for an attacker to run a dictionary campaign on.
85
+ We do not use salt (or, more precisely, we use a fixed salt), as this is meant for direct use,
86
+ not to store the key in a DB. To compensate, the number o iterations is set especially high:
87
+ on the computer this was developed it takes ~1 sec to execute and is almost triple the
88
+ recommended amount of 600,000 (see https://en.wikipedia.org/wiki/PBKDF2).
89
+
90
+ The salt and the iteration number were randomly generated when this method was written
91
+ so as to be unique to this implementation and not a standard one that can have a standard
92
+ dictionary (i.e. attacks would have to generate a dictionary specific to this implementation).
93
+ ON THE OTHER HAND, this only serves the purpose of generating keys from static passwords.
94
+ NEVER use this method to save a database of keys. ONLY use it for direct user input.
95
+
96
+ Docs: https://cryptography.io/en/latest/
97
+
98
+ Args:
99
+ str_password (str): Non-empty string password; empty spaces at start/end are IGNORED
100
+
101
+ Returns:
102
+ AESKey crypto key to use (URL-safe base64-encoded 32-byte key)
103
+
104
+ Raises:
105
+ Error: empty password
106
+ """
107
+ str_password = str_password.strip()
108
+ if not str_password:
109
+ raise base.InputError('empty passwords not allowed, for safety reasons')
110
+ kdf = hazmat_pbkdf2.PBKDF2HMAC(
111
+ algorithm=hazmat_hashes.SHA256(), length=32,
112
+ salt=_PASSWORD_SALT_256, iterations=_PASSWORD_ITERATIONS)
113
+ return cls(key256=kdf.derive(str_password.encode('utf-8')))
114
+
115
+ class ECBEncoderClass(base.SymmetricCrypto):
116
+ """The simplest encryption possible (UNSAFE if misused): 128 bit block AES-ECB, 256 bit key.
117
+
118
+ Please DO **NOT** use this for regular cryptography. For regular crypto use Encrypt()/Decrypt().
119
+ This class was specifically built to encode/decode 128 bit / 16 bytes blocks using a
120
+ pre-existing key. No measures are taken here to prevent timing attacks.
121
+ """
122
+
123
+ def __init__(self, key256: AESKey, /) -> None:
124
+ """Constructor.
125
+
126
+ Args:
127
+ key256 (AESKey): key
128
+ """
129
+ self._cipher: ciphers.Cipher[modes.ECB] = ciphers.Cipher(
130
+ algorithms.AES256(key256.key256), modes.ECB())
131
+ assert self._cipher.algorithm.key_size == 256, 'should never happen: AES256+ECB should have 256 bits key'
132
+ assert self._cipher.algorithm.block_size == 128, 'should never happen: AES256+ECB should have 128 bits block' # type:ignore
133
+
134
+ def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
135
+ """Encrypt a 128 bits block (16 bytes) `plaintext` and return `ciphertext` of 128 bits.
136
+
137
+ Please DO **NOT** use this for regular cryptography.
138
+ No measures are taken here to prevent timing attacks.
139
+
140
+ Args:
141
+ plaintext (bytes): Data to encrypt.
142
+ associated_data (bytes, optional): DO NOT USE - not supported in ECB mode
143
+
144
+ Returns:
145
+ bytes: Ciphertext, a block of 128 bits (16 bytes)
146
+
147
+ Raises:
148
+ InputError: invalid inputs
149
+ """
150
+ if associated_data is not None:
151
+ raise base.InputError('AES/ECB does not support associated_data')
152
+ if len(plaintext) != 16:
153
+ raise base.InputError(f'plaintext must be 16 bytes long, got {len(plaintext)}')
154
+ encryptor: ciphers.CipherContext = self._cipher.encryptor()
155
+ return encryptor.update(plaintext) + encryptor.finalize()
156
+
157
+ def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
158
+ """Decrypt a 128 bits block (16 bytes) `ciphertext` and return original 128 bits `plaintext`.
159
+
160
+ Please DO **NOT** use this for regular cryptography.
161
+ No measures are taken here to prevent timing attacks.
162
+
163
+ Args:
164
+ ciphertext (bytes): Data to decrypt (including any embedded nonce/tag if applicable)
165
+ associated_data (bytes, optional): DO NOT USE - not supported in ECB mode
166
+
167
+ Returns:
168
+ bytes: Decrypted plaintext, a block of 128 bits (16 bytes)
169
+
170
+ Raises:
171
+ InputError: invalid inputs
172
+ """
173
+ if associated_data is not None:
174
+ raise base.InputError('AES/ECB does not support associated_data')
175
+ if len(ciphertext) != 16:
176
+ raise base.InputError(f'ciphertext must be 16 bytes long, got {len(ciphertext)}')
177
+ decryptor: ciphers.CipherContext = self._cipher.decryptor()
178
+ return decryptor.update(ciphertext) + decryptor.finalize()
179
+
180
+ def EncryptHex(self, plaintext_hex: str, /) -> str:
181
+ """Encrypt a 256 bits hexadecimal block, outputting also a 256 bits hexadecimal block."""
182
+ return base.BytesToHex(self.Encrypt(base.HexToBytes(plaintext_hex)))
183
+
184
+ def DecryptHex(self, ciphertext_hex: str, /) -> str:
185
+ """Decrypt a 256 bits hexadecimal block, outputting also a 256 bits hexadecimal block."""
186
+ return base.BytesToHex(self.Decrypt(base.HexToBytes(ciphertext_hex)))
187
+
188
+ def ECBEncoder(self) -> AESKey.ECBEncoderClass:
189
+ """Return a AESKey.ECBEncoderClass object using this key."""
190
+ return AESKey.ECBEncoderClass(self)
191
+
192
+ def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
193
+ """Encrypt `plaintext` and return `ciphertext` with AES-256 + GCM algorithm.
194
+
195
+ <https://en.wikipedia.org/wiki/Galois/Counter_Mode>
196
+ <https://cryptography.io/en/latest/hazmat/primitives/symmetric-encryption/#cryptography.hazmat.primitives.ciphers.modes.GCM>
197
+
198
+ No measures are taken here to prevent timing attacks.
199
+
200
+ Args:
201
+ plaintext (bytes): Data to encrypt.
202
+ associated_data (bytes, optional): Optional AAD (Authenticated Associated Data),
203
+ AEAD mode (authenticated encryption with associated data); must be provided
204
+ again on decrypt
205
+
206
+ Returns:
207
+ bytes: Ciphertext; if a nonce/tag is needed for decryption, the implementation
208
+ must encode it within the returned bytes (or document how to retrieve it)
209
+
210
+ Raises:
211
+ InputError: invalid inputs
212
+ CryptoError: internal crypto failures
213
+ """
214
+ iv: bytes = base.RandBytes(16)
215
+ cipher: ciphers.Cipher[modes.GCM] = ciphers.Cipher(
216
+ algorithms.AES256(self.key256), modes.GCM(iv))
217
+ assert cipher.algorithm.key_size == 256, 'should never happen: AES256+GCM should have 256 bits key'
218
+ assert cipher.algorithm.block_size == 128, 'should never happen: AES256+GCM should have 128 bits block' # type:ignore
219
+ encryptor: ciphers.CipherContext = cipher.encryptor()
220
+ if associated_data:
221
+ encryptor.authenticate_additional_data(associated_data) # type:ignore
222
+ ciphertext: bytes = encryptor.update(plaintext) + encryptor.finalize() # GCM doesn't need padding
223
+ tag: bytes = encryptor.tag # type:ignore
224
+ assert len(iv) == 16, 'should never happen: AES256+GCM should have 128 bits IV/nonce'
225
+ assert len(tag) == 16, 'should never happen: AES256+GCM should have 128 bits tag'
226
+ return iv + ciphertext + tag
227
+
228
+ def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
229
+ """Decrypt `ciphertext` and return the original `plaintext` with AES-256 + GCM algorithm.
230
+
231
+ <https://en.wikipedia.org/wiki/Galois/Counter_Mode>
232
+ <https://cryptography.io/en/latest/hazmat/primitives/symmetric-encryption/#cryptography.hazmat.primitives.ciphers.modes.GCM>
233
+
234
+ No measures are taken here to prevent timing attacks.
235
+
236
+ Args:
237
+ ciphertext (bytes): Data to decrypt (including any embedded nonce/tag if applicable)
238
+ associated_data (bytes, optional): Optional AAD (Authenticated Associated Data);
239
+ must match what was used during encrypt
240
+
241
+ Returns:
242
+ bytes: Decrypted plaintext bytes
243
+
244
+ Raises:
245
+ InputError: invalid inputs
246
+ CryptoError: internal crypto failures, authentication failure, key mismatch, etc
247
+ """
248
+ assert len(ciphertext) >= 32, 'should never happen: AES256+GCM should have ≥32 bytes IV/CT/tag'
249
+ iv, tag = ciphertext[:16], ciphertext[-16:]
250
+ decryptor: ciphers.CipherContext = ciphers.Cipher(
251
+ algorithms.AES256(self.key256), modes.GCM(iv, tag)).decryptor()
252
+ if associated_data:
253
+ decryptor.authenticate_additional_data(associated_data) # type:ignore
254
+ try:
255
+ return decryptor.update(ciphertext[16:-16]) + decryptor.finalize()
256
+ except crypt_exceptions.InvalidTag as err:
257
+ raise base.CryptoError('failed decryption') from err