227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
# built-in dependencies
|
||
import random
|
||
from typing import Optional, List
|
||
import math
|
||
|
||
# 3rd party dependencies
|
||
import sympy
|
||
from sympy import jacobi_symbol
|
||
from tqdm import tqdm
|
||
|
||
# project dependencies
|
||
from lightphe.models.Homomorphic import Homomorphic
|
||
from lightphe.commons.logger import Logger
|
||
|
||
logger = Logger(module="lightphe/cryptosystems/GoldwasserMicali.py")
|
||
|
||
# pylint:disable=consider-using-enumerate
|
||
|
||
|
||
class GoldwasserMicali(Homomorphic):
|
||
"""
|
||
Goldwasser-Micali algorithm is homomorphic with respect to the Exclusively OR (XOR).
|
||
Ref: sefiks.com/2023/10/27/a-step-by-step-partially-homomorphic-encryption-example-with-goldwasser-micali-in-python/
|
||
"""
|
||
|
||
REQUIRED_KEYS = {
|
||
"public_key": ["n", "x"],
|
||
"private_key": ["p", "q"],
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
keys: Optional[dict] = None,
|
||
key_size: Optional[int] = None,
|
||
max_tries: int = 10000,
|
||
):
|
||
"""
|
||
Args:
|
||
keys (dict): private - public key pair.
|
||
set this to None if you want to generate random keys.
|
||
key_size (int): key size in bits
|
||
max_tries (int): maximum attempts to generate keys
|
||
"""
|
||
|
||
self.keys = keys or self.generate_keys(
|
||
key_size=key_size or 1024, max_tries=max_tries
|
||
)
|
||
self.ciphertext_modulo = self.keys["public_key"]["n"]
|
||
|
||
# Plaintext can be any integer (even larger than n) because it is internally
|
||
# encrypted bit by bit. Internally, each bit uses modulo 2, but LightPHE expects
|
||
# integers, not bits. The original integer can be fully restored after decryption
|
||
# even if it is larger than n.
|
||
self.plaintext_modulo = self.keys["public_key"]["n"]
|
||
|
||
def generate_keys(self, key_size: int, max_tries: int = 10000) -> dict:
|
||
"""
|
||
Generate public and private keys of Goldwasser-Micali cryptosystem
|
||
Args:
|
||
key_size (int): key size in bits
|
||
max_tries (int): maximum number of tries to generate keys
|
||
Returns:
|
||
keys (dict): having private_key and public_key keys
|
||
"""
|
||
for attempt in tqdm(range(max_tries), disable=True):
|
||
# pick large random primes p and q
|
||
p = sympy.randprime(2 ** (key_size // 2 - 100), 2 ** (key_size // 2) - 1)
|
||
q = sympy.randprime(2 ** (key_size // 2 - 100), 2 ** (key_size // 2) - 1)
|
||
|
||
# to prevent factorizatin attacks, it is recommended that n should be
|
||
# several hundred bits or more
|
||
n = p * q
|
||
|
||
# find quadratic non-residue x
|
||
for _ in range(1000): # try max 1000 random x
|
||
x = random.randint(2, n - 1)
|
||
if math.gcd(x, n) != 1:
|
||
continue
|
||
if jacobi_symbol(x, p) == -1 and jacobi_symbol(x, q) == -1:
|
||
break
|
||
else:
|
||
# # If no suitable x is found after 1000 tries, discard the current p–q pair and retry
|
||
continue
|
||
|
||
keys = {
|
||
"public_key": {"n": n, "x": x},
|
||
"private_key": {"p": p, "q": q},
|
||
}
|
||
|
||
logger.debug(
|
||
f"Goldwasser-Micali keys generated after {attempt+1} attempts, n bits: {n.bit_length()}"
|
||
)
|
||
|
||
return keys
|
||
|
||
raise RuntimeError(
|
||
f"Failed to generate Goldwasser-Micali keys after {max_tries} attempts."
|
||
"Please try to rerun."
|
||
)
|
||
|
||
def generate_random_key(self) -> int:
|
||
"""
|
||
Goldwasser-Micali requires to generate one-time random key that co-prime to n
|
||
Returns:
|
||
random key (int): one time random key for encryption
|
||
"""
|
||
n = self.keys["public_key"]["n"]
|
||
while True:
|
||
r = random.randint(1, n)
|
||
if math.gcd(r, n) == 1:
|
||
break
|
||
return r
|
||
|
||
def encrypt(self, plaintext: int, random_key: Optional[int] = None) -> List[int]:
|
||
"""
|
||
Encrypt a given plaintext for optionally given random key with Goldwasser-Micali
|
||
Args:
|
||
plaintext (int): message to encrypt
|
||
random_key (int): Goldwasser-Micali requires a random key
|
||
Random key will be generated automatically if you do not set this.
|
||
Returns:
|
||
ciphertext (int): encrypted message
|
||
"""
|
||
n = self.keys["public_key"]["n"]
|
||
x = self.keys["public_key"]["x"]
|
||
|
||
m_binary = bin(plaintext)[2:]
|
||
|
||
# number of bits
|
||
k = len(m_binary)
|
||
|
||
if random_key and len(random_key) != k:
|
||
raise ValueError(f"Random key must be length of {k}")
|
||
|
||
c = []
|
||
for i in range(0, k):
|
||
mi = int(m_binary[i])
|
||
|
||
if random_key:
|
||
ri = random_key[i]
|
||
else:
|
||
ri = self.generate_random_key()
|
||
|
||
ci = (pow(ri, 2, n) * pow(x, mi, n)) % n
|
||
c.append(ci)
|
||
|
||
return c
|
||
|
||
def decrypt(self, ciphertext: List[int]) -> int:
|
||
"""
|
||
Decrypt a given ciphertext with Goldwasser-Micali
|
||
Args:
|
||
ciphertext (int): encrypted message
|
||
Returns:
|
||
plaintext (int): restored message
|
||
"""
|
||
m_binaries = []
|
||
|
||
p = self.keys["private_key"]["p"]
|
||
q = self.keys["private_key"]["q"]
|
||
|
||
for i in ciphertext:
|
||
xp = i % p
|
||
xq = i % q
|
||
|
||
# reaches int limit for 3072-bit key
|
||
# if pow(xp, int((p - 1) / 2), p) == 1 and pow(xq, int((q - 1) / 2), q) == 1:
|
||
if (
|
||
pow(xp, int((p - 1) // 2), p) == 1
|
||
and pow(xq, int((q - 1) // 2), q) == 1
|
||
):
|
||
m_binaries.append("0")
|
||
else:
|
||
m_binaries.append("1")
|
||
|
||
m_binary = "".join(m_binaries)
|
||
return int(m_binary, 2)
|
||
|
||
def xor(self, ciphertext1: List[int], ciphertext2: List[int]) -> List[int]:
|
||
"""
|
||
Perform homomorphic xor on encrypted data.
|
||
Result of this must be equal to E(m1 ^ m2) = E(m1) ^ E(m2)
|
||
Encryption calculations are done in module n
|
||
Args:
|
||
ciphertext1 (list of int): 1st ciphertext created with Goldwasser-Micali
|
||
ciphertext2 (list of int): 2nd ciphertext created with Goldwasser-Micali
|
||
Returns:
|
||
ciphertext3 (list of int): 3rd ciphertext created with Goldwasser-Micali
|
||
"""
|
||
if len(ciphertext1) > len(ciphertext2):
|
||
ciphertext2 = [self.encrypt(0)[0]] * (
|
||
len(ciphertext1) - len(ciphertext2)
|
||
) + ciphertext2
|
||
elif len(ciphertext2) > len(ciphertext1):
|
||
ciphertext1 = [self.encrypt(0)[0]] * (
|
||
len(ciphertext2) - len(ciphertext1)
|
||
) + ciphertext1
|
||
|
||
assert len(ciphertext1) == len(ciphertext2)
|
||
|
||
ciphertext3 = []
|
||
for i in range(0, len(ciphertext1)):
|
||
c1 = ciphertext1[i]
|
||
c2 = ciphertext2[i]
|
||
|
||
ciphertext3.append((c1 * c2) % self.ciphertext_modulo)
|
||
|
||
return ciphertext3
|
||
|
||
def reencrypt(self, ciphertext: List[int]) -> List[int]:
|
||
"""
|
||
Re-encrypt a given ciphertext with Goldwasser-Micali
|
||
Args:
|
||
ciphertext (int): encrypted message
|
||
Returns:
|
||
ciphertext (int): re-encrypted message
|
||
"""
|
||
n = self.keys["public_key"]["n"]
|
||
|
||
c_reencrypted = []
|
||
for ci in ciphertext:
|
||
ri = self.generate_random_key()
|
||
ci_reencrypted = (pow(ri, 2, n) * ci) % n
|
||
c_reencrypted.append(ci_reencrypted)
|
||
|
||
return c_reencrypted
|