transcrypto 1.1.2__py3-none-any.whl → 1.2.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.
transcrypto/elgamal.py CHANGED
@@ -23,8 +23,7 @@ import logging
23
23
  # import pdb
24
24
  from typing import Self
25
25
 
26
- from . import base
27
- from . import modmath
26
+ from . import base, modmath, aes
28
27
 
29
28
  __author__ = 'balparda@github.com'
30
29
  __version__: str = base.__version__ # version comes from base!
@@ -33,14 +32,16 @@ __version_tuple__: tuple[int, ...] = base.__version_tuple__
33
32
 
34
33
  _MAX_KEY_GENERATION_FAILURES = 15
35
34
 
35
+ # fixed prefixes: do NOT ever change! will break all encryption and signature schemes
36
+ _ELGAMAL_ENCRYPTION_AAD_PREFIX = b'transcrypto.ElGamal.Encryption.1.0\x00'
37
+ _ELGAMAL_SIGNATURE_HASH_PREFIX = b'transcrypto.ElGamal.Signature.1.0\x00'
38
+
36
39
 
37
40
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
38
41
  class ElGamalSharedPublicKey(base.CryptoKey):
39
42
  """El-Gamal shared public key. This key can be shared by a group.
40
43
 
41
- BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
42
- These are pedagogical/raw primitives; do not use for new protocols.
43
- No measures are taken here to prevent timing attacks.
44
+ BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
44
45
 
45
46
  Attributes:
46
47
  prime_modulus (int): prime modulus, ≥ 7
@@ -69,9 +70,41 @@ class ElGamalSharedPublicKey(base.CryptoKey):
69
70
  string representation of ElGamalSharedPublicKey
70
71
  """
71
72
  return ('ElGamalSharedPublicKey('
73
+ f'bits={self.prime_modulus.bit_length()}, '
72
74
  f'prime_modulus={base.IntToEncoded(self.prime_modulus)}, '
73
75
  f'group_base={base.IntToEncoded(self.group_base)})')
74
76
 
77
+ @property
78
+ def modulus_size(self) -> int:
79
+ """Modulus size in bytes. The number of bytes used in Encrypt/Decrypt/Sign/Verify."""
80
+ return (self.prime_modulus.bit_length() + 7) // 8
81
+
82
+ def _DomainSeparatedHash(
83
+ self, message: bytes, associated_data: bytes | None, salt: bytes, /) -> int:
84
+ """Compute the domain-separated hash for signing and verifying.
85
+
86
+ Args:
87
+ message (bytes): message to sign/verify
88
+ associated_data (bytes | None): optional associated data
89
+ salt (bytes): salt to use in the hash
90
+
91
+ Returns:
92
+ int: integer representation of the hash output;
93
+ Hash512("prefix" || len(aad) || aad || message || salt)
94
+
95
+ Raises:
96
+ CryptoError: hash output is out of range
97
+ """
98
+ aad: bytes = b'' if associated_data is None else associated_data
99
+ la: bytes = base.IntToFixedBytes(len(aad), 8)
100
+ assert len(salt) == 64, 'should never happen: salt should be exactly 64 bytes'
101
+ y: int = base.BytesToInt(
102
+ base.Hash512(_ELGAMAL_SIGNATURE_HASH_PREFIX + la + aad + message + salt))
103
+ if not 1 < y < self.prime_modulus:
104
+ # will only reasonably happen if modulus is small
105
+ raise base.CryptoError(f'hash output {y} is out of range/invalid {self.prime_modulus}')
106
+ return y
107
+
75
108
  @classmethod
76
109
  def NewShared(cls, bit_length: int, /) -> Self:
77
110
  """Make a new shared public key of `bit_length` bits.
@@ -89,19 +122,18 @@ class ElGamalSharedPublicKey(base.CryptoKey):
89
122
  if bit_length < 11:
90
123
  raise base.InputError(f'invalid bit length: {bit_length=}')
91
124
  # generate random prime and number, create object (should never fail)
92
- return cls(
93
- prime_modulus=modmath.NBitRandomPrime(bit_length),
94
- group_base=base.RandBits(bit_length - 1),
95
- )
125
+ p: int = modmath.NBitRandomPrime(bit_length)
126
+ g: int = 0
127
+ while not 2 < g < p - 1:
128
+ g = base.RandBits(bit_length)
129
+ return cls(prime_modulus=p, group_base=g)
96
130
 
97
131
 
98
132
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
99
- class ElGamalPublicKey(ElGamalSharedPublicKey):
133
+ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
100
134
  """El-Gamal public key. This is an individual public key.
101
135
 
102
- BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
103
- These are pedagogical/raw primitives; do not use for new protocols.
104
- No measures are taken here to prevent timing attacks.
136
+ BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
105
137
 
106
138
  Attributes:
107
139
  individual_base (int): individual encryption public base, 3 ≤ i < prime_modulus
@@ -126,7 +158,8 @@ class ElGamalPublicKey(ElGamalSharedPublicKey):
126
158
  Returns:
127
159
  string representation of ElGamalPublicKey
128
160
  """
129
- return (f'ElGamalPublicKey({super(ElGamalPublicKey, self).__str__()}, ' # pylint: disable=super-with-arguments
161
+ return ('ElGamalPublicKey('
162
+ f'{super(ElGamalPublicKey, self).__str__()}, ' # pylint: disable=super-with-arguments
130
163
  f'individual_base={base.IntToEncoded(self.individual_base)})')
131
164
 
132
165
  def _MakeEphemeralKey(self) -> tuple[int, int]:
@@ -141,14 +174,16 @@ class ElGamalPublicKey(ElGamalSharedPublicKey):
141
174
  bit_length: int = self.prime_modulus.bit_length()
142
175
  while (not 1 < ephemeral_key < p_1 or
143
176
  ephemeral_key in (self.group_base, self.individual_base)):
144
- ephemeral_key = base.RandBits(bit_length - 1)
177
+ ephemeral_key = base.RandBits(bit_length)
145
178
  if base.GCD(ephemeral_key, p_1) != 1:
146
179
  ephemeral_key = 0 # we have to try again
147
180
  return (ephemeral_key, modmath.ModInv(ephemeral_key, p_1))
148
181
 
149
- def Encrypt(self, message: int, /) -> tuple[int, int]:
182
+ def RawEncrypt(self, message: int, /) -> tuple[int, int]:
150
183
  """Encrypt `message` with this public key.
151
184
 
185
+ BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
186
+ These are pedagogical/raw primitives; do not use for new protocols.
152
187
  We explicitly disallow `message` to be zero.
153
188
 
154
189
  Args:
@@ -164,17 +199,66 @@ class ElGamalPublicKey(ElGamalSharedPublicKey):
164
199
  if not 0 < message < self.prime_modulus:
165
200
  raise base.InputError(f'invalid message: {message=}')
166
201
  # encrypt
167
- ephemeral_key: int = self._MakeEphemeralKey()[0]
168
- a, b = 0, 0
202
+ a: int = 0
203
+ b: int = 0
169
204
  while a < 2 or b < 2:
205
+ ephemeral_key: int = self._MakeEphemeralKey()[0]
170
206
  a = modmath.ModExp(self.group_base, ephemeral_key, self.prime_modulus)
171
207
  s: int = modmath.ModExp(self.individual_base, ephemeral_key, self.prime_modulus)
172
208
  b = (message * s) % self.prime_modulus
173
209
  return (a, b)
174
210
 
175
- def VerifySignature(self, message: int, signature: tuple[int, int], /) -> bool:
211
+ def Encrypt(self, plaintext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
212
+ """Encrypt `plaintext` and return `ciphertext`.
213
+
214
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
215
+ • Pick random r ∈ [2, n-1]
216
+ • ct1, ct2 = ElGamal(r)
217
+ • return Padded(ct1, k) + Padded(ct2, k) +
218
+ AES-256-GCM(key=SHA512(r)[32:], plaintext,
219
+ associated_data="prefix" + len(aad) + aad +
220
+ Padded(ct1, k) + Padded(ct2, k))
221
+
222
+ We pick fresh random r, send ct = ElGamal(r), and derive the DEM key from r,
223
+ then use AES-GCM for the payload. This is the classic El-Gamal-KEM construction.
224
+ With AEAD as the DEM, we get strong confidentiality and ciphertext integrity
225
+ (CCA resistance in the ROM under standard assumptions). There are no
226
+ Bleichenbacher-style issue because we do not expose any padding semantics.
227
+
228
+ Args:
229
+ plaintext (bytes): Data to encrypt.
230
+ associated_data (bytes, optional): Optional AAD; must be provided again on decrypt
231
+
232
+ Returns:
233
+ bytes: Ciphertext; see above:
234
+ Padded(ct1, k) + Padded(ct2, k) + AES-256-GCM(key=SHA512(r)[32:], plaintext,
235
+ associated_data="prefix" + len(aad) + aad +
236
+ Padded(ct1, k) + Padded(ct2, k))
237
+
238
+ Raises:
239
+ InputError: invalid inputs
240
+ CryptoError: internal crypto failures
241
+ """
242
+ # generate random r and encrypt it
243
+ r: int = 0
244
+ while not 1 < r < self.prime_modulus - 1:
245
+ r = base.RandBits(self.prime_modulus.bit_length())
246
+ k: int = self.modulus_size
247
+ i_ct: tuple[int, int] = self.RawEncrypt(r)
248
+ ct: bytes = base.IntToFixedBytes(i_ct[0], k) + base.IntToFixedBytes(i_ct[1], k)
249
+ assert len(ct) == 2 * k, 'should never happen: c_kem should be exactly 2k bytes'
250
+ # encrypt plaintext with AES-256-GCM using SHA512(r)[32:] as key; return ct || Encrypt(...)
251
+ ss: bytes = base.Hash512(base.IntToFixedBytes(r, k))
252
+ aad: bytes = b'' if associated_data is None else associated_data
253
+ aad_prime: bytes = (
254
+ _ELGAMAL_ENCRYPTION_AAD_PREFIX + base.IntToFixedBytes(len(aad), 8) + aad + ct)
255
+ return ct + aes.AESKey(key256=ss[32:]).Encrypt(plaintext, associated_data=aad_prime)
256
+
257
+ def RawVerify(self, message: int, signature: tuple[int, int], /) -> bool:
176
258
  """Verify a signature. True if OK; False if failed verification.
177
259
 
260
+ BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
261
+ These are pedagogical/raw primitives; do not use for new protocols.
178
262
  We explicitly disallow `message` to be zero.
179
263
 
180
264
  Args:
@@ -199,6 +283,42 @@ class ElGamalPublicKey(ElGamalSharedPublicKey):
199
283
  c: int = modmath.ModExp(self.individual_base, signature[0], self.prime_modulus)
200
284
  return a == (b * c) % self.prime_modulus
201
285
 
286
+ def Verify(
287
+ self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None) -> bool:
288
+ """Verify a `signature` for `message`. True if OK; False if failed verification.
289
+
290
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
291
+ • Split signature in 3 parts: the first 64 bytes is salt, the rest is s1 and s2
292
+ • y_check = ElGamal(s1, s2)
293
+ • return y_check == Hash512("prefix" || len(aad) || aad || message || salt)
294
+ • return False for any malformed signature
295
+
296
+ Args:
297
+ message (bytes): Data that was signed
298
+ signature (bytes): Signature data to verify
299
+ associated_data (bytes, optional): Optional AAD (must match what was used during signing)
300
+
301
+ Returns:
302
+ True if signature is valid, False otherwise
303
+
304
+ Raises:
305
+ InputError: invalid inputs
306
+ CryptoError: internal crypto failures, authentication failure, key mismatch, etc
307
+ """
308
+ k: int = self.modulus_size
309
+ if k <= 64:
310
+ raise base.InputError(f'modulus too small for signing operations: {k} bytes')
311
+ if len(signature) != (64 + k + k):
312
+ logging.info(f'invalid signature length: {len(signature)} ; expected {64 + k + k}')
313
+ return False
314
+ try:
315
+ return self.RawVerify(
316
+ self._DomainSeparatedHash(message, associated_data, signature[:64]),
317
+ (base.BytesToInt(signature[64:64 + k]), base.BytesToInt(signature[64 + k:])))
318
+ except base.InputError as err:
319
+ logging.info(err)
320
+ return False
321
+
202
322
  @classmethod
203
323
  def Copy(cls, other: ElGamalPublicKey, /) -> Self:
204
324
  """Initialize a public key by taking the public parts of a public/private key."""
@@ -209,12 +329,10 @@ class ElGamalPublicKey(ElGamalSharedPublicKey):
209
329
 
210
330
 
211
331
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
212
- class ElGamalPrivateKey(ElGamalPublicKey):
332
+ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer): # pylint: disable=too-many-ancestors
213
333
  """El-Gamal private key.
214
334
 
215
- BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
216
- These are pedagogical/raw primitives; do not use for new protocols.
217
- No measures are taken here to prevent timing attacks.
335
+ BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
218
336
 
219
337
  Attributes:
220
338
  decrypt_exp (int): individual decryption exponent, 3 ≤ i < prime_modulus
@@ -243,12 +361,16 @@ class ElGamalPrivateKey(ElGamalPublicKey):
243
361
  Returns:
244
362
  string representation of ElGamalPrivateKey without leaking secrets
245
363
  """
246
- return (f'ElGamalPrivateKey({super(ElGamalPrivateKey, self).__str__()}, ' # pylint: disable=super-with-arguments
364
+ return ('ElGamalPrivateKey('
365
+ f'{super(ElGamalPrivateKey, self).__str__()}, ' # pylint: disable=super-with-arguments
247
366
  f'decrypt_exp={base.ObfuscateSecret(self.decrypt_exp)})')
248
367
 
249
- def Decrypt(self, ciphertext: tuple[int, int], /) -> int:
368
+ def RawDecrypt(self, ciphertext: tuple[int, int], /) -> int:
250
369
  """Decrypt `ciphertext` tuple with this private key.
251
370
 
371
+ BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
372
+ These are pedagogical/raw primitives; do not use for new protocols.
373
+
252
374
  Args:
253
375
  ciphertext (tuple[int, int]): ciphertext to decrypt, 0 ≤ c1,c2 < modulus
254
376
 
@@ -267,9 +389,47 @@ class ElGamalPrivateKey(ElGamalPublicKey):
267
389
  ciphertext[0], self.prime_modulus - 1 - self.decrypt_exp, self.prime_modulus)
268
390
  return (ciphertext[1] * csi) % self.prime_modulus
269
391
 
270
- def Sign(self, message: int, /) -> tuple[int, int]:
392
+ def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
393
+ """Decrypt `ciphertext` and return the original `plaintext`.
394
+
395
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
396
+ • Split ciphertext in 3 parts: k bytes for ct1, k bytes for ct2, the rest is AES-256-GCM
397
+ • r = ElGamal(ct1, ct2)
398
+ • return AES-256-GCM(key=SHA512(r)[32:], ciphertext,
399
+ associated_data="prefix" + len(aad) + aad +
400
+ Padded(ct1, k) + Padded(ct2, k))
401
+
402
+ Args:
403
+ ciphertext (bytes): Data to decrypt; see Encrypt() above:
404
+ Padded(ct1, k) + Padded(ct2, k) +
405
+ AES-256-GCM(key=SHA512(r)[32:], plaintext,
406
+ associated_data="prefix" + len(aad) + aad + Padded(ct1, k) + Padded(ct2, k))
407
+ associated_data (bytes, optional): Optional AAD (must match what was used during encrypt)
408
+
409
+ Returns:
410
+ bytes: Decrypted plaintext bytes
411
+
412
+ Raises:
413
+ InputError: invalid inputs
414
+ CryptoError: internal crypto failures, authentication failure, key mismatch, etc
415
+ """
416
+ k: int = self.modulus_size
417
+ if len(ciphertext) < (k + k + 32):
418
+ raise base.InputError(f'invalid ciphertext length: {len(ciphertext)} ; {k=}')
419
+ # split ciphertext in 3 parts: the first 2k bytes is ct, the rest is AES-256-GCM
420
+ ct1, ct2, aes_ct = ciphertext[:k], ciphertext[k:2 * k], ciphertext[2 * k:]
421
+ r: int = self.RawDecrypt((base.BytesToInt(ct1), base.BytesToInt(ct2)))
422
+ ss: bytes = base.Hash512(base.IntToFixedBytes(r, k))
423
+ aad: bytes = b'' if associated_data is None else associated_data
424
+ aad_prime: bytes = (
425
+ _ELGAMAL_ENCRYPTION_AAD_PREFIX + base.IntToFixedBytes(len(aad), 8) + aad + ct1 + ct2)
426
+ return aes.AESKey(key256=ss[32:]).Decrypt(aes_ct, associated_data=aad_prime)
427
+
428
+ def RawSign(self, message: int, /) -> tuple[int, int]:
271
429
  """Sign `message` with this private key.
272
430
 
431
+ BEWARE: This is raw El-Gamal, no ECIES-style KEM/DEM padding or validation! This is **NOT** DSA!
432
+ These are pedagogical/raw primitives; do not use for new protocols.
273
433
  We explicitly disallow `message` to be zero.
274
434
 
275
435
  Args:
@@ -285,13 +445,49 @@ class ElGamalPrivateKey(ElGamalPublicKey):
285
445
  if not 0 < message < self.prime_modulus:
286
446
  raise base.InputError(f'invalid message: {message=}')
287
447
  # sign
288
- a, b, p_1 = 0, 0, self.prime_modulus - 1
448
+ a: int = 0
449
+ b: int = 0
450
+ p_1: int = self.prime_modulus - 1
289
451
  while a < 2 or b < 2:
290
452
  ephemeral_key, ephemeral_inv = self._MakeEphemeralKey()
291
453
  a = modmath.ModExp(self.group_base, ephemeral_key, self.prime_modulus)
292
454
  b = (ephemeral_inv * ((message - a * self.decrypt_exp) % p_1)) % p_1
293
455
  return (a, b)
294
456
 
457
+ def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
458
+ """Sign `message` and return the `signature`.
459
+
460
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
461
+ • Pick random salt of 64 bytes
462
+ • s1, s2 = ElGamal(Hash512("prefix" || len(aad) || aad || message || salt))
463
+ • return salt || Padded(s1, k) || Padded(s2, k)
464
+
465
+ This is basically Full-Domain Hash El-Gamal with a 512-bit hash and per-signature salt,
466
+ which is EUF-CMA secure in the ROM. Our domain-separation prefix and explicit AAD
467
+ length prefix are both correct and remove composition/ambiguity pitfalls.
468
+ There are no Bleichenbacher-style issue because we do not expose any padding semantics.
469
+
470
+ Args:
471
+ message (bytes): Data to sign.
472
+ associated_data (bytes, optional): Optional AAD for AEAD modes; must be
473
+ provided again on decrypt
474
+
475
+ Returns:
476
+ bytes: Signature; salt || Padded(s, k) - see above
477
+
478
+ Raises:
479
+ InputError: invalid inputs
480
+ CryptoError: internal crypto failures
481
+ """
482
+ k: int = self.modulus_size
483
+ if k <= 64:
484
+ raise base.InputError(f'modulus too small for signing operations: {k} bytes')
485
+ salt: bytes = base.RandBytes(64)
486
+ s_int: tuple[int, int] = self.RawSign(self._DomainSeparatedHash(message, associated_data, salt))
487
+ s_bytes: bytes = base.IntToFixedBytes(s_int[0], k) + base.IntToFixedBytes(s_int[1], k)
488
+ assert len(s_bytes) == 2 * k, 'should never happen: s_bytes should be exactly 2k bytes'
489
+ return salt + s_bytes
490
+
295
491
  @classmethod
296
492
  def New(cls, shared_key: ElGamalSharedPublicKey, /) -> Self:
297
493
  """Make a new private key based on an existing shared public key.
@@ -318,7 +514,7 @@ class ElGamalPrivateKey(ElGamalPublicKey):
318
514
  decrypt_exp: int = 0
319
515
  while (not 2 < decrypt_exp < shared_key.prime_modulus - 1 or
320
516
  decrypt_exp == shared_key.group_base):
321
- decrypt_exp = base.RandBits(bit_length - 1)
517
+ decrypt_exp = base.RandBits(bit_length)
322
518
  # make the object
323
519
  return cls(
324
520
  prime_modulus=shared_key.prime_modulus,