A Python port of the Invisible Internet Project (I2P)
1"""ElGamal — 2048-bit ElGamal encryption.
2
3Ported from net.i2p.crypto.ElGamalEngine.
4Uses Python's native int arithmetic (arbitrary precision).
5
6I2P-specific format:
7- Max plaintext: 222 bytes
8- Ciphertext: always 514 bytes (two 257-byte components)
9- Prepends random nonzero byte + SHA-256 hash before encryption
10- Verifies hash on decryption
11"""
12
13import hashlib
14import os
15
16# I2P's 2048-bit ElGamal prime (from CryptoConstants.java)
17ELGAMAL_PRIME = int(
18 "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
19 "29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
20 "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
21 "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
22 "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
23 "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
24 "83655D23DCA3AD961C62F356208552BB9ED529077096966D"
25 "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
26 "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
27 "DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
28 "15728E5A8AACAA68FFFFFFFFFFFFFFFF", 16
29)
30
31# Generator
32ELGAMAL_GENERATOR = 2
33
34# Max plaintext size: prime_size(256) - 1(random) - 32(hash) - 1(format) = 222
35MAX_CLEARTEXT = 222
36
37# Component size in output (257 bytes each, zero-padded)
38COMPONENT_SIZE = 257
39
40# Total ciphertext size
41CIPHERTEXT_SIZE = COMPONENT_SIZE * 2 # 514
42
43
44class ElGamalEngine:
45 """ElGamal encryption/decryption with I2P-specific formatting.
46
47 Uses 2048-bit prime from CryptoConstants.
48 Not recommended for new applications — prefer X25519/ChaCha20.
49 """
50
51 @staticmethod
52 def encrypt(data: bytes, public_key: bytes) -> bytes:
53 """Encrypt up to 222 bytes using ElGamal.
54
55 Args:
56 data: plaintext, max 222 bytes
57 public_key: 256-byte ElGamal public key
58
59 Returns:
60 514-byte ciphertext (two 257-byte components)
61
62 Raises:
63 ValueError: if data exceeds 222 bytes
64 """
65 if len(data) > MAX_CLEARTEXT:
66 raise ValueError(f"Data too long: {len(data)} > {MAX_CLEARTEXT}")
67
68 # Build plaintext: [random_nonzero_byte][sha256(data)][data]
69 rand_byte = 0
70 while rand_byte == 0:
71 rand_byte = os.urandom(1)[0]
72 hash_val = hashlib.sha256(data).digest()
73 plaintext = bytes([rand_byte]) + hash_val + data
74
75 # Pad to 256 bytes
76 plaintext = plaintext.ljust(256, b"\x00")
77
78 # Convert to integer (big-endian)
79 m = int.from_bytes(plaintext, "big")
80
81 # Convert public key to integer
82 y = int.from_bytes(public_key[:256], "big")
83
84 # Pick random k, compute y_val = g^k mod p, d = m * y^k mod p
85 k = int.from_bytes(os.urandom(256), "big") % (ELGAMAL_PRIME - 2) + 1
86 y_val = pow(ELGAMAL_GENERATOR, k, ELGAMAL_PRIME)
87 d = (m * pow(y, k, ELGAMAL_PRIME)) % ELGAMAL_PRIME
88
89 # Encode as 257-byte components (zero-padded big-endian)
90 y_bytes = y_val.to_bytes(COMPONENT_SIZE, "big")
91 d_bytes = d.to_bytes(COMPONENT_SIZE, "big")
92
93 return y_bytes + d_bytes
94
95 @staticmethod
96 def decrypt(encrypted: bytes, private_key: bytes) -> bytes | None:
97 """Decrypt ElGamal ciphertext.
98
99 Args:
100 encrypted: 514-byte ciphertext
101 private_key: 256-byte ElGamal private key
102
103 Returns:
104 Decrypted data, or None if hash verification fails.
105 """
106 if len(encrypted) != CIPHERTEXT_SIZE:
107 return None
108
109 # Extract components
110 y_bytes = encrypted[:COMPONENT_SIZE]
111 d_bytes = encrypted[COMPONENT_SIZE:]
112
113 y_val = int.from_bytes(y_bytes, "big")
114 d = int.from_bytes(d_bytes, "big")
115
116 # Convert private key to integer
117 x = int.from_bytes(private_key[:256], "big")
118
119 # Compute m = d * y^(-x) mod p = d * y^(p-1-x) mod p
120 m = (d * pow(y_val, ELGAMAL_PRIME - 1 - x, ELGAMAL_PRIME)) % ELGAMAL_PRIME
121
122 # Convert back to bytes (256 bytes)
123 plaintext = m.to_bytes(256, "big")
124
125 # Parse: [random_byte][32-byte hash][data_with_padding]
126 hash_val = plaintext[1:33]
127 padded_data = plaintext[33:]
128
129 # Try all possible data lengths (longest first) using the hash
130 # to find the correct boundary between data and zero-padding.
131 for length in range(len(padded_data), -1, -1):
132 candidate = padded_data[:length]
133 if hashlib.sha256(candidate).digest() == hash_val:
134 return candidate
135
136 return None
137
138 @staticmethod
139 def generate_keypair() -> tuple[bytes, bytes]:
140 """Generate ElGamal key pair.
141
142 Returns:
143 (public_key, private_key) — both 256 bytes
144 """
145 # Random private key x in [1, p-2]
146 x = int.from_bytes(os.urandom(256), "big") % (ELGAMAL_PRIME - 2) + 1
147 # Public key y = g^x mod p
148 y = pow(ELGAMAL_GENERATOR, x, ELGAMAL_PRIME)
149
150 pub = y.to_bytes(256, "big")
151 priv = x.to_bytes(256, "big")
152 return pub, priv