├── docs └── index.md ├── tests ├── __init__.py ├── test_radix_decomposition.py ├── test_rns.py ├── test_multiplication.py ├── test_encryption.py ├── test_addition.py ├── test_subtraction.py └── test_glwe_dist.py ├── venum ├── __init__.py ├── logging.py ├── numeric.py ├── plaintext_encoding.py ├── evaluation.py ├── crt.py ├── rns.py ├── key.py ├── glwe.py └── encryption.py ├── Containerfile ├── .gitignore ├── pyproject.toml ├── justfile ├── LICENSE.md └── README.md /docs/index.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /venum/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /venum/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('venum') 4 | FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s] %(message)s" 5 | logging.basicConfig(format=FORMAT) 6 | logger.setLevel(logging.NOTSET) 7 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | ENV VIRTUAL_ENV=/opt/venv 4 | RUN python3 -m venv $VIRTUAL_ENV 5 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 6 | 7 | WORKDIR /workspace 8 | 9 | COPY . . 10 | 11 | RUN $VIRTUAL_ENV/bin/pip install build \ 12 | && $VIRTUAL_ENV/bin/pip install -e . 13 | 14 | RUN $VIRTUAL_ENV/bin/python -m pytest tests/ \ 15 | && $VIRTUAL_ENV/bin/python -m build --wheel 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | build/ 11 | dist/ 12 | *.egg-info/ 13 | .eggs/ 14 | 15 | # Virtual environments 16 | venv/ 17 | ENV/ 18 | env/ 19 | .venv/ 20 | .env/ 21 | 22 | # IDEs and editors 23 | .vscode/ 24 | .idea/ 25 | *.sublime-project 26 | *.sublime-workspace 27 | 28 | # Just 29 | .cache/ 30 | 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "venum-python-backend" 7 | version = "0.1.0" 8 | description = "A Python library for ..." 9 | authors = [{name = "Vaultree", email = "vaultree@vaultree.com"}] 10 | license = {text = "MIT"} 11 | dependencies = [ 12 | "black", 13 | "flake8", 14 | "pytest", 15 | "sympy", 16 | ] 17 | 18 | [tool.setuptools] 19 | packages = ["venum"] 20 | 21 | [tool.pytest.ini_options] 22 | minversion = "6.0" 23 | addopts = "-v" 24 | testpaths = ["tests"] 25 | -------------------------------------------------------------------------------- /tests/test_radix_decomposition.py: -------------------------------------------------------------------------------- 1 | from venum import numeric 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize("number, radix, expected", [ 7 | # radix 2 8 | (0, 2, [0]), 9 | (1, 2, [1]), 10 | (10, 2, [1, 0, 1, 0]), 11 | (100, 2, [1, 1, 0, 0, 1, 0, 0]), 12 | (10000, 2, [1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0]), 13 | (1234512345, 2, [1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 14 | 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1]), 15 | 16 | # radix 10 17 | (0, 10, [0]), 18 | (1, 10, [1]), 19 | (10, 10, [1, 0]), 20 | (100, 10, [1, 0, 0]), 21 | (10000, 10, [1, 0, 0, 0, 0]), 22 | (1234512345, 10, [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]), 23 | ]) 24 | def test_radix_decomposition(number, radix, expected): 25 | for i in range(len(expected)): 26 | actual = numeric.nth_digit(number, radix, i) 27 | assert actual == expected[-1 - i] 28 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | package-name := "venum" 2 | env := "env" 3 | python := env + "/bin/python" 4 | pip := env + "/bin/pip" 5 | global-python := "python3" 6 | container-engine := "podman" 7 | image-name := package-name + "-build" 8 | container-build-dir := "./dist_container" 9 | 10 | test path='./': 11 | {{ python }} -m pytest tests/{{path}} 12 | 13 | setup: 14 | @echo "Setting up virtual environment" 15 | @{{ global-python }} -m venv {{ env }} 16 | @{{ pip }} install -e . 17 | @{{ pip }} install build 18 | 19 | build: 20 | {{ python }} -m build --wheel 21 | 22 | build-container: 23 | #!/bin/sh 24 | {{ container-engine }} build . -f Containerfile -t {{ image-name }} 25 | temp_container=$({{ container-engine }} create {{ image-name }}) 26 | mkdir -p {{ container-build-dir }} 27 | {{ container-engine }} cp $temp_container:/workspace/dist {{ container-build-dir }} 28 | {{ container-engine }} rm -v $temp_container 29 | mv {{ container-build-dir }}/dist/* {{ container-build-dir }} 30 | rmdir {{ container-build-dir }}/dist 31 | 32 | clean: 33 | rm -rf dist/ 34 | rm -rf {{ container-build-dir }}/ 35 | rm -rf build/ 36 | rm -rf *.egg-info 37 | 38 | format: 39 | @{{ python }} -m black {{ package-name }}/ tests/ 40 | 41 | lint: 42 | @{{ python }} -m flake8 {{ package-name }}/ tests/ 43 | 44 | repl: 45 | @{{ python }} 46 | 47 | run path: 48 | @{{ python }} {{ path }} 49 | -------------------------------------------------------------------------------- /tests/test_rns.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from venum.rns import RnsBasis 3 | 4 | 5 | @pytest.fixture 6 | def rns_basis(): 7 | return RnsBasis([3, 5, 7]) 8 | 9 | 10 | def test_initialization(rns_basis): 11 | rns_number = rns_basis.to_rns(10) 12 | expected = [10 % m for m in rns_basis.moduli] 13 | assert rns_number.residues == expected, "RNS initialization failed." 14 | 15 | 16 | def test_addition(rns_basis): 17 | x = 10 18 | y = 6 19 | a = rns_basis.to_rns(x) 20 | b = rns_basis.to_rns(y) 21 | result = a + b 22 | expected = [(x + y) % m for m in rns_basis.moduli] 23 | assert result.residues == expected, \ 24 | f"Expected addition result {expected}, got {result.residues}" 25 | 26 | 27 | def test_subtraction(rns_basis): 28 | x = 10 29 | y = 6 30 | a = rns_basis.to_rns(x) 31 | b = rns_basis.to_rns(y) 32 | result = a - b 33 | expected = [(x - y) % m for m in rns_basis.moduli] 34 | assert result.residues == expected, \ 35 | f"Expected subtraction result {expected}, got {result.residues}" 36 | 37 | 38 | def test_multiplication(rns_basis): 39 | x = 10 40 | y = 6 41 | a = rns_basis.to_rns(x) 42 | b = rns_basis.to_rns(y) 43 | result = a * b 44 | expected = [(x * y) % m for m in rns_basis.moduli] 45 | assert result.residues == expected, \ 46 | f"Expected multiplication result {expected}, got {result.residues}" 47 | 48 | 49 | def test_to_int(rns_basis): 50 | x = 10 51 | a = rns_basis.to_rns(x) 52 | assert a.to_int() == x, f"Expected int value {x}, got {int(a)}" 53 | -------------------------------------------------------------------------------- /tests/test_multiplication.py: -------------------------------------------------------------------------------- 1 | from venum.glwe import EncryptionParameters, GlweDistribution 2 | from venum.encryption import Encryptor 3 | from venum.plaintext_encoding import PolynomialEncoder 4 | from venum.key import gen_key_pair, RelinKey 5 | from venum.evaluation import Evaluator 6 | 7 | from sympy import Poly 8 | from sympy.abc import x 9 | import pytest 10 | 11 | 12 | @pytest.mark.skip(reason="multiplication needs fixing") 13 | @pytest.mark.parametrize( 14 | "input", 15 | [ 16 | { 17 | "params": EncryptionParameters( 18 | dimension=4, 19 | ciphertext_modulus=1400472361734830353, 20 | plaintext_modulus=12289, 21 | noise_modulus=3, 22 | ), 23 | "lhs": [0, 0, 0, 0], 24 | "rhs": [0, 0, 0, 0], 25 | }, 26 | ]) 27 | def test_multiplication(input): 28 | params, lhs, rhs = input["params"], input["lhs"], input["rhs"] 29 | 30 | dist = GlweDistribution(params) 31 | 32 | expected = (Poly(reversed(lhs), x, domain=dist.plaintext_ring) * 33 | Poly(reversed(rhs), x, domain=dist.plaintext_ring) % 34 | dist.poly_modulus.set_domain(dist.plaintext_ring)) 35 | expected = list(reversed(expected.all_coeffs())) 36 | 37 | sk, pk = gen_key_pair(dist) 38 | encryptor = Encryptor(dist, PolynomialEncoder(dist)) 39 | lhs_cipher = encryptor.encrypt(pk, lhs) 40 | rhs_cipher = encryptor.encrypt(pk, rhs) 41 | relin_key = RelinKey.from_secret_key(sk) 42 | eval = Evaluator(dist, relin_key) 43 | cipher_result = eval.mul(lhs_cipher, rhs_cipher) 44 | decrypted = encryptor.decrypt(sk, cipher_result) 45 | assert decrypted == expected 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause Clear License 2 | 3 | Copyright © 2024 Vaultree. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions, and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions, and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 3. All advertising materials mentioning features or use of this software must display the following acknowledgment: This product includes software developed by Vaultree and its contributors. 10 | 4. Neither the name of Vaultree nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY VAULTREE AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL VAULTREE OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /venum/numeric.py: -------------------------------------------------------------------------------- 1 | from sympy import Poly 2 | from sympy.abc import x 3 | 4 | from typing import Iterable 5 | 6 | 7 | def nth_digit(number, radix, n): 8 | """ 9 | Return the n-th digit of a number in a given radix. 10 | 11 | Args: 12 | - number: int, the number to extract the digit from. 13 | - radix: int, the base of the number system. 14 | - n: int, the index of the digit to extract.j 15 | 16 | Returns: 17 | - int, the n-th digit of the number in the given radix. 18 | 19 | Raises: 20 | - ValueError: if number is negative, radix is less than 2, or n is negative 21 | """ 22 | 23 | if number < 0: 24 | raise ValueError("Number must be non-negative.") 25 | if radix < 2: 26 | raise ValueError("Radix must be at least 2.") 27 | if n < 0: 28 | raise ValueError("Index n must be non-negative.") 29 | 30 | for _ in range(n): 31 | number //= radix 32 | 33 | return number % radix 34 | 35 | 36 | def radix_decompose_poly(poly: Poly, radix: int, 37 | num_components: int, domain) -> Iterable[Poly]: 38 | """ 39 | Decompose a polynomial into its components in a given radix. 40 | The components are obtained by extracting the digits of the coefficients 41 | and constructing a new polynomial from them. 42 | 43 | Args: 44 | - poly: Poly, the polynomial to decompose. 45 | - radix: int, the base of the number system. 46 | - num_components: int, the number of components to extract. 47 | - domain: Domain, the domain of the polynomial. 48 | 49 | Returns: 50 | - Iterable[Poly], the components of the polynomial. 51 | """ 52 | 53 | for n in range(num_components): 54 | coeffs = (nth_digit(coef, radix, n) 55 | for coef in reversed(poly.all_coeffs())) 56 | decomposed = Poly(coeffs, x, domain=domain) 57 | yield decomposed 58 | -------------------------------------------------------------------------------- /tests/test_encryption.py: -------------------------------------------------------------------------------- 1 | from venum.glwe import EncryptionParameters, GlweDistribution 2 | from venum.encryption import Encryptor 3 | from venum.plaintext_encoding import PolynomialEncoder 4 | from venum.key import gen_key_pair 5 | 6 | import pytest 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "input", 11 | [ 12 | { 13 | "params": EncryptionParameters( 14 | dimension=4, 15 | ciphertext_modulus=383, 16 | plaintext_modulus=127, 17 | noise_modulus=3, 18 | seed=0 19 | ), 20 | "message": [1, 2, 3, 4] 21 | }, 22 | { 23 | "params": EncryptionParameters( 24 | dimension=4, 25 | ciphertext_modulus=12289, 26 | plaintext_modulus=127, 27 | noise_modulus=3, 28 | seed=1 29 | ), 30 | "message": [5, 6, 7, 8] 31 | }, 32 | { 33 | "params": EncryptionParameters( 34 | dimension=4, 35 | ciphertext_modulus=383, 36 | plaintext_modulus=127, 37 | noise_modulus=3, 38 | ), 39 | "message": [5, 6, 7, 8] 40 | }, 41 | { 42 | "params": EncryptionParameters( 43 | dimension=4, 44 | ciphertext_modulus=12289, 45 | plaintext_modulus=127, 46 | noise_modulus=3, 47 | ), 48 | "message": [1, 2, 3, 4] 49 | }, 50 | ]) 51 | def test_encrypt_decrypt(input): 52 | params, message = input["params"], input["message"] 53 | dist = GlweDistribution(params) 54 | sk, pk = gen_key_pair(dist) 55 | encryptor = Encryptor(dist, PolynomialEncoder(dist)) 56 | cipher = encryptor.encrypt(pk, message) 57 | decrypted = encryptor.decrypt(sk, cipher) 58 | assert decrypted == message 59 | -------------------------------------------------------------------------------- /venum/plaintext_encoding.py: -------------------------------------------------------------------------------- 1 | from sympy import Poly 2 | from sympy.abc import x 3 | from typing import Iterable 4 | 5 | from abc import ABC 6 | 7 | 8 | class Encoder(ABC): 9 | """ 10 | Interface for encoding and decoding plaintexts. 11 | """ 12 | 13 | def encode(self, message: Iterable[int]) -> Poly: 14 | pass 15 | 16 | def decode(self, poly: Poly) -> Iterable[int]: 17 | pass 18 | 19 | 20 | class PolynomialEncoder(Encoder): 21 | """ 22 | Encodes messages as a polynomials by mapping each element to a 23 | coefficient of the polynomial in increasing order of degree. 24 | """ 25 | 26 | def __init__(self, dist): 27 | """ 28 | Initializes the PolynomialEncoder with the given GLWE distribution. 29 | """ 30 | 31 | self.dist = dist 32 | 33 | def encode(self, message: Iterable[int]) -> Poly: 34 | """ 35 | Encodes the given message as a polynomial by mapping each element to a 36 | coefficient of the polynomial in increasing order of degree. 37 | 38 | Args: 39 | - message: The message to encode. 40 | 41 | Returns: 42 | - A polynomial representing the message. 43 | """ 44 | 45 | return Poly(reversed(message), x, domain=self.dist.plaintext_ring) 46 | 47 | def decode(self, poly: Poly) -> Iterable[int]: 48 | """ 49 | Decodes the given polynomial by extracting the coefficients and 50 | returning them as an iterable. 51 | 52 | Args: 53 | - poly: The polynomial to decode. 54 | 55 | Returns: 56 | - The message represented by the polynomial. 57 | """ 58 | 59 | q = self.dist.params.ciphertext_modulus 60 | p0 = self.dist.params.plaintext_modulus 61 | p1 = self.dist.params.noise_modulus 62 | p0p1 = p0 * p1 63 | k = (q // (2 * p0p1)) * p0p1 64 | 65 | coeffs = reversed(poly.all_coeffs()) 66 | 67 | coeffs = list((((coef + k) % q) % p0p1) % p0 68 | for coef in coeffs) 69 | if len(coeffs) < self.dist.params.dimension: 70 | coeffs += [0] * (self.dist.params.dimension - len(coeffs)) 71 | return coeffs 72 | 73 | 74 | class BatchEncoder(Encoder): 75 | def __init__(self, dist): 76 | self.dist = dist 77 | 78 | def encode(self, message: Iterable[int]) -> Poly: 79 | raise NotImplementedError 80 | 81 | def decode(self, poly: Poly) -> Iterable[int]: 82 | raise NotImplementedError 83 | -------------------------------------------------------------------------------- /tests/test_addition.py: -------------------------------------------------------------------------------- 1 | from venum.glwe import EncryptionParameters, GlweDistribution 2 | from venum.encryption import Encryptor 3 | from venum.plaintext_encoding import PolynomialEncoder 4 | from venum.key import gen_key_pair 5 | from venum.evaluation import Evaluator 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "input", 12 | [ 13 | { 14 | "params": EncryptionParameters( 15 | dimension=4, 16 | ciphertext_modulus=383, 17 | plaintext_modulus=127, 18 | noise_modulus=3, 19 | seed=0 20 | ), 21 | "lhs": [1, 2, 3, 4], 22 | "rhs": [5, 6, 7, 8], 23 | }, 24 | { 25 | "params": EncryptionParameters( 26 | dimension=4, 27 | ciphertext_modulus=12289, 28 | plaintext_modulus=127, 29 | noise_modulus=3, 30 | seed=1 31 | ), 32 | "lhs": [1, 2, 3, 4], 33 | "rhs": [5, 6, 7, 8], 34 | }, 35 | { 36 | "params": EncryptionParameters( 37 | dimension=4, 38 | ciphertext_modulus=383, 39 | plaintext_modulus=127, 40 | noise_modulus=3, 41 | ), 42 | "lhs": [1, 2, 3, 4], 43 | "rhs": [5, 6, 7, 8], 44 | }, 45 | { 46 | "params": EncryptionParameters( 47 | dimension=4, 48 | ciphertext_modulus=12289, 49 | plaintext_modulus=127, 50 | noise_modulus=3, 51 | ), 52 | "lhs": [1, 2, 3, 4], 53 | "rhs": [5, 6, 7, 8], 54 | }, 55 | { 56 | "params": EncryptionParameters( 57 | dimension=4, 58 | ciphertext_modulus=1400472361734830353, 59 | plaintext_modulus=12289, 60 | noise_modulus=3, 61 | ), 62 | "lhs": [0, 0, 0, 0], 63 | "rhs": [0, 0, 0, 0], 64 | }, 65 | { 66 | "params": EncryptionParameters( 67 | dimension=4, 68 | ciphertext_modulus=1400472361734830353, 69 | plaintext_modulus=12289, 70 | noise_modulus=3, 71 | ), 72 | "lhs": [10001, 10002, 10003, 10004], 73 | "rhs": [4, 3, 2, 1], 74 | }, 75 | ]) 76 | def test_addition(input): 77 | params, lhs, rhs = input["params"], input["lhs"], input["rhs"] 78 | expected = [x + y for x, y in zip(lhs, rhs)] 79 | 80 | dist = GlweDistribution(params) 81 | sk, pk = gen_key_pair(dist) 82 | encryptor = Encryptor(dist, PolynomialEncoder(dist)) 83 | lhs_cipher = encryptor.encrypt(pk, lhs) 84 | rhs_cipher = encryptor.encrypt(pk, rhs) 85 | eval = Evaluator(dist) 86 | cipher_result = eval.add(lhs_cipher, rhs_cipher) 87 | decrypted = encryptor.decrypt(sk, cipher_result) 88 | assert decrypted == expected 89 | -------------------------------------------------------------------------------- /tests/test_subtraction.py: -------------------------------------------------------------------------------- 1 | from venum.glwe import EncryptionParameters, GlweDistribution 2 | from venum.encryption import Encryptor 3 | from venum.plaintext_encoding import PolynomialEncoder 4 | from venum.key import gen_key_pair 5 | from venum.evaluation import Evaluator 6 | 7 | import pytest 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "input", 12 | [ 13 | { 14 | "params": EncryptionParameters( 15 | dimension=4, 16 | ciphertext_modulus=383, 17 | plaintext_modulus=127, 18 | noise_modulus=3, 19 | seed=0 20 | ), 21 | "lhs": [5, 6, 7, 8], 22 | "rhs": [1, 2, 3, 4], 23 | }, 24 | { 25 | "params": EncryptionParameters( 26 | dimension=4, 27 | ciphertext_modulus=12289, 28 | plaintext_modulus=127, 29 | noise_modulus=3, 30 | seed=1 31 | ), 32 | "lhs": [5, 6, 7, 8], 33 | "rhs": [1, 2, 3, 4], 34 | }, 35 | { 36 | "params": EncryptionParameters( 37 | dimension=4, 38 | ciphertext_modulus=383, 39 | plaintext_modulus=127, 40 | noise_modulus=3, 41 | ), 42 | "lhs": [5, 6, 7, 8], 43 | "rhs": [1, 2, 3, 4], 44 | }, 45 | { 46 | "params": EncryptionParameters( 47 | dimension=4, 48 | ciphertext_modulus=12289, 49 | plaintext_modulus=127, 50 | noise_modulus=3, 51 | ), 52 | "lhs": [5, 6, 7, 8], 53 | "rhs": [1, 2, 3, 4], 54 | }, 55 | { 56 | "params": EncryptionParameters( 57 | dimension=4, 58 | ciphertext_modulus=1400472361734830353, 59 | plaintext_modulus=12289, 60 | noise_modulus=3, 61 | ), 62 | "lhs": [0, 0, 0, 0], 63 | "rhs": [0, 0, 0, 0], 64 | }, 65 | { 66 | "params": EncryptionParameters( 67 | dimension=4, 68 | ciphertext_modulus=1400472361734830353, 69 | plaintext_modulus=12289, 70 | noise_modulus=3, 71 | ), 72 | "lhs": [10001, 10002, 10003, 10004], 73 | "rhs": [4, 3, 2, 1], 74 | }, 75 | ]) 76 | def test_subtraction(input): 77 | params, lhs, rhs = input["params"], input["lhs"], input["rhs"] 78 | expected = [x - y for x, y in zip(lhs, rhs)] 79 | 80 | dist = GlweDistribution(params) 81 | sk, pk = gen_key_pair(dist) 82 | encryptor = Encryptor(dist, PolynomialEncoder(dist)) 83 | lhs_cipher = encryptor.encrypt(pk, lhs) 84 | rhs_cipher = encryptor.encrypt(pk, rhs) 85 | eval = Evaluator(dist) 86 | cipher_result = eval.sub(lhs_cipher, rhs_cipher) 87 | decrypted = encryptor.decrypt(sk, cipher_result) 88 | assert decrypted == expected 89 | -------------------------------------------------------------------------------- /venum/evaluation.py: -------------------------------------------------------------------------------- 1 | from .logging import logger 2 | from .glwe import GlweDistribution, GlweSample 3 | from .key import RelinKey 4 | from .encryption import Cipher, Rank2Cipher 5 | 6 | 7 | class Evaluator: 8 | """ 9 | Evaluator class for performing arithmetic operations on encrypted data. 10 | 11 | Attributes: 12 | - dist: GlweDistribution, the distribution used for encryption 13 | - relin_key: RelinKey, the relinearization key used for homomorphic 14 | multiplication. If not provided, multiplication will raise an error. 15 | """ 16 | 17 | def __init__(self, dist: GlweDistribution, relin_key: RelinKey = None): 18 | logger.debug(f"Initializing Evaluator with {dist} and {relin_key}") 19 | self._dist = dist 20 | self.relin_key = relin_key 21 | 22 | @property 23 | def dist(self): 24 | return self._dist 25 | 26 | def add(self, lhs: Cipher, rhs: Cipher): 27 | """ 28 | Add two ciphertexts together. 29 | 30 | Args: 31 | - lhs: Cipher, the left-hand side of the addition 32 | - rhs: Cipher, the right-hand side of the addition 33 | 34 | Returns: 35 | - Cipher, the sum of the two ciphertexts 36 | """ 37 | 38 | logger.debug(f"Adding {lhs} and {rhs}") 39 | mask = lhs.glwe_sample.mask + rhs.glwe_sample.mask 40 | body = lhs.glwe_sample.body + rhs.glwe_sample.body 41 | return Cipher(GlweSample(mask=mask, body=body)) 42 | 43 | def sub(self, lhs: Cipher, rhs: Cipher): 44 | """ 45 | Subtract one ciphertext from another. 46 | 47 | Args: 48 | - lhs: Cipher, the left-hand side of the subtraction 49 | - rhs: Cipher, the right-hand side of the subtraction 50 | 51 | Returns: 52 | - Cipher, the difference of the two ciphertexts 53 | """ 54 | 55 | logger.debug(f"Subtracting {lhs} and {rhs}") 56 | mask = lhs.glwe_sample.mask - rhs.glwe_sample.mask 57 | body = lhs.glwe_sample.body - rhs.glwe_sample.body 58 | return Cipher(GlweSample(mask=mask, body=body)) 59 | 60 | def _compute_rank2_product(self, lhs: GlweSample, 61 | rhs: GlweSample) -> Rank2Cipher: 62 | logger.debug(f"Computing rank 2 product of {lhs} and {rhs}") 63 | constant = lhs.body * rhs.body 64 | constant = constant % self.dist.poly_modulus 65 | 66 | linear = lhs.body * rhs.mask + lhs.mask * rhs.body 67 | linear = linear % self.dist.poly_modulus 68 | 69 | quadratic = lhs.mask * rhs.mask 70 | quadratic = quadratic % self.dist.poly_modulus 71 | 72 | return Rank2Cipher(constant, linear, quadratic) 73 | 74 | def mul(self, lhs: Cipher, rhs: Cipher): 75 | """ 76 | Multiply two ciphertexts together. 77 | 78 | Args: 79 | - lhs: Cipher, the left-hand side of the multiplication 80 | - rhs: Cipher, the right-hand side of the multiplication 81 | 82 | Returns: 83 | - Cipher, the product of the two ciphertexts 84 | """ 85 | 86 | if self.relin_key is None: 87 | raise ValueError("No relinearization key provided") 88 | 89 | # FIXME: either multiplication or relinearization needs 90 | # debugging. 91 | raise NotImplementedError( 92 | "Multiplication support is not yet implemented") 93 | 94 | logger.debug(f"Multiplying {lhs} and {rhs}") 95 | rank2 = self._compute_rank2_product(lhs.glwe_sample, rhs.glwe_sample) 96 | return rank2.relinearize(self.relin_key, self.dist.poly_modulus) 97 | -------------------------------------------------------------------------------- /venum/crt.py: -------------------------------------------------------------------------------- 1 | from .rns import Rns 2 | from .logging import logger 3 | 4 | from sympy import Poly 5 | from sympy.abc import x 6 | 7 | 8 | class CrtEncoder: 9 | def __init__(self, basis, plaintext_ring): 10 | """ 11 | CRT encoder for encoding and decoding polynomials 12 | with respect to a pair of moduli. 13 | CRT encoding is described on https://eprint.iacr.org/2024/1105.pdf. 14 | 15 | Args: 16 | - basis: a pair of moduli for the CRT encoding 17 | - plaintext_ring: the ring of the plaintext polynomials 18 | """ 19 | 20 | if len(basis) != 2: 21 | raise ValueError("CRT encoding requires two moduli") 22 | self.basis = basis 23 | self.plaintext_ring = plaintext_ring 24 | 25 | def _encode_coef(self, message, noise): 26 | return Rns(self.basis, [message, noise]).to_int() 27 | 28 | def _decode_coef(self, value): 29 | return self.basis.to_rns(value) 30 | 31 | def _normalized_coeffs(self, message: Poly, noise: Poly): 32 | message_coeffs = (message.all_coeffs() 33 | if not message.is_zero 34 | else [0] * len(noise.coeffs())) 35 | noise_coeffs = (noise.all_coeffs() 36 | if not noise.is_zero 37 | else [0] * len(message.coeffs())) 38 | diff_len = len(message_coeffs) - len(noise_coeffs) 39 | if diff_len > 0: 40 | noise_coeffs += [0] * diff_len 41 | elif diff_len < 0: 42 | message_coeffs += [0] * -diff_len 43 | logger.debug(f'message_coeffs: {message_coeffs}') 44 | logger.debug(f'noise_coeffs: {noise_coeffs}') 45 | return zip(message_coeffs, noise_coeffs) 46 | 47 | def encode(self, message: Poly, noise: Poly): 48 | """ 49 | Encode a message and noise polynomial into a single polynomial 50 | using the CRT encoding. 51 | 52 | Args: 53 | - message: the message polynomial 54 | - noise: the noise polynomial 55 | 56 | Returns: 57 | - a CRT-encoded polynomial 58 | """ 59 | 60 | logger.debug(f'CRT encoding message: {message} with noise: {noise}') 61 | msg_noise_pairs = self._normalized_coeffs(message, noise) 62 | coefs = (self._encode_coef(msg_coef, noise_coef) 63 | for (msg_coef, noise_coef) in msg_noise_pairs) 64 | return Poly(coefs, x, domain=self.plaintext_ring) 65 | 66 | def _encode_with_zero(self, poly: Poly, component: int): 67 | zero = Poly(0, x, domain=self.plaintext_ring) 68 | if component == 0: 69 | return self.encode(poly, zero) 70 | elif component == 1: 71 | return self.encode(zero, poly) 72 | else: 73 | raise ValueError("component must be 0 or 1") 74 | 75 | def encode_pure_message(self, message: Poly): 76 | """ 77 | Encode a message polynomial with zero noise. 78 | 79 | Args: 80 | - message: the message polynomial 81 | 82 | Returns: 83 | - a CRT-encoded polynomial 84 | """ 85 | 86 | return self._encode_with_zero(message, 0) 87 | 88 | def encode_pure_noise(self, noise: Poly): 89 | """ 90 | Encode a noise polynomial with zero message. 91 | 92 | Args: 93 | - noise: the noise polynomial 94 | 95 | Returns: 96 | - a CRT-encoded polynomial 97 | """ 98 | 99 | return self._encode_with_zero(noise, 1) 100 | 101 | def decode(self, poly: Poly): 102 | """ 103 | Decode a CRT-encoded polynomial into its message and noise components. 104 | """ 105 | 106 | logger.debug(f'CRT decoding polynomial: {poly}') 107 | return [self._decode_coef(coeff) 108 | for coeff in poly.coeffs()] 109 | -------------------------------------------------------------------------------- /tests/test_glwe_dist.py: -------------------------------------------------------------------------------- 1 | from venum.glwe import EncryptionParameters, GlweDistribution, GlweSample 2 | 3 | import pytest 4 | from sympy import Poly 5 | from sympy.abc import x 6 | 7 | 8 | @pytest.fixture 9 | def params(): 10 | return EncryptionParameters( 11 | dimension=4, 12 | ciphertext_modulus=383, 13 | plaintext_modulus=127, 14 | noise_modulus=3, 15 | seed=0 16 | ) 17 | 18 | 19 | def test_poly_coeffs_within_bounds(params): 20 | dist = GlweDistribution(params) 21 | poly = dist.sample_polynomial() 22 | assert all(-params.ciphertext_modulus <= x <= 23 | params.ciphertext_modulus for x in poly.all_coeffs()) 24 | 25 | 26 | def test_poly_dimension_within_bounds(params): 27 | dist = GlweDistribution(params) 28 | poly = dist.sample_polynomial() 29 | assert poly.degree() == params.dimension - 1 30 | 31 | 32 | @pytest.fixture 33 | def zero_sample_polys(): 34 | return { 35 | "mask": Poly(x**3 + 1, x), 36 | "secret": Poly(x**2 + 1, x), 37 | "crt_noise": Poly(x + 1, x), 38 | "expected_body": Poly(x**3 + x**2 + 2), 39 | } 40 | 41 | 42 | def test_zero_sample(params, zero_sample_polys): 43 | dist = GlweDistribution(params) 44 | 45 | mask = zero_sample_polys["mask"].set_domain(dist.cipher_ring) 46 | secret = zero_sample_polys["secret"].set_domain(dist.cipher_ring) 47 | crt_noise = zero_sample_polys["crt_noise"].set_domain(dist.cipher_ring) 48 | expected_body = zero_sample_polys["expected_body"].set_domain( 49 | dist.cipher_ring) 50 | 51 | sample = GlweSample._compute_zero_sample( 52 | mask, secret, crt_noise, dist.poly_modulus 53 | ) 54 | assert sample.mask == -mask 55 | assert sample.body == expected_body 56 | 57 | 58 | @pytest.fixture 59 | def sample_polys(): 60 | """Fixture to provide test polynomials.""" 61 | poly_modulus = Poly(x**3 + 1, x) # Example modulus polynomial 62 | mask = Poly(x + 1, x) 63 | noise = Poly(2*x + 3, x) 64 | body = Poly(x**2 + 2, x) 65 | mask_noise = Poly(3*x + 2, x) 66 | body_noise = Poly(x + 4, x) 67 | message = Poly(2*x**2 + x + 1, x) 68 | u = Poly(x + 1, x) 69 | return { 70 | "poly_modulus": poly_modulus, 71 | "mask": mask, 72 | "noise": noise, 73 | "body": body, 74 | "mask_noise": mask_noise, 75 | "body_noise": body_noise, 76 | "message": message, 77 | "u": u 78 | } 79 | 80 | 81 | def test_compute_mask(sample_polys): 82 | mask = sample_polys["mask"] 83 | noise = sample_polys["noise"] 84 | u = sample_polys["u"] 85 | poly_modulus = sample_polys["poly_modulus"] 86 | 87 | result = GlweSample._compute_mask( 88 | mask, noise, 89 | u, poly_modulus) 90 | expected = (mask * u + noise) % poly_modulus 91 | assert result == expected 92 | 93 | 94 | def test_compute_body(sample_polys): 95 | body = sample_polys["body"] 96 | noise = sample_polys["body_noise"] 97 | message = sample_polys["message"] 98 | u = sample_polys["u"] 99 | poly_modulus = sample_polys["poly_modulus"] 100 | 101 | result = GlweSample._compute_body( 102 | body, noise, 103 | message, u, poly_modulus) 104 | expected = (body * u + message + noise) % poly_modulus 105 | assert result == expected 106 | 107 | 108 | def test_compute_sample(sample_polys): 109 | mask = sample_polys["mask"] 110 | mask_noise = sample_polys["mask_noise"] 111 | body = sample_polys["body"] 112 | body_noise = sample_polys["body_noise"] 113 | message = sample_polys["message"] 114 | u = sample_polys["u"] 115 | poly_modulus = sample_polys["poly_modulus"] 116 | 117 | sample = GlweSample.compute_sample( 118 | mask, mask_noise, body, body_noise, 119 | message, u, poly_modulus 120 | ) 121 | expected_mask = (mask * u + mask_noise) % poly_modulus 122 | expected_body = (body * u + message + body_noise) % poly_modulus 123 | assert sample.mask == expected_mask 124 | assert sample.body == expected_body 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VENum Python backend 2 | 3 | Welcome to the Python backend for VENum! VENum stands for Vaultree Encrypted Numbers and is Vaultree's homomorphic encryption library. This open-source project provides a straightforward Python implementation of homomorphic encryption, designed for ease of use. 4 | 5 | ## General Features and goals 6 | 7 | - **Homomorphic Encryption**: Perform computations on encrypted data without decrypting it, ensuring data privacy and security. 8 | - **Python Backend**: A simple and accessible Python-based implementation for developers and researchers. 9 | - **Seamless Interchangeability**: The current code is a low-level API for homomorphic encryption and while it can be used directly, the intended use is through our VENumpy API which allows the Python backend to be swapped for Vaultree's closed-source Rust implementation without modifying your code. 10 | - **Flexible Licensing**: The Python backend is open-source and free to use, while the Rust backend offers significant performance improvements for real-world scenarios and requires a license to access. 11 | 12 | ## Acknowledgments 13 | 14 | This project is part of Vaultree's mission to make privacy-preserving computation accessible and practical for everyone. 15 | 16 | ## Homomorphic Encryption Features 17 | 18 | ### Current 19 | - Addition of ciphers 20 | - Subtraction of ciphers 21 | 22 | ### Upcoming 23 | - Multiplication of ciphers 24 | 25 | ### Future Direction 26 | We aim to provide all features available in the Rust backend. They are described in detail in the following Vaultree research papers: 27 | - [Efficient and Practical Homomorphic Encryption Framework](https://eprint.iacr.org/2024/1105.pdf) 28 | - [Advanced Cryptographic Techniques for Scalable Privacy](https://eprint.iacr.org/2024/1622.pdf) 29 | 30 | ## Usage 31 | 32 | ### Dependencies 33 | 34 | Before using the `justfile`, ensure you have the following installed: 35 | 36 | - [Just command runner](https://github.com/casey/just) 37 | - Python 3 38 | - Container Engine: `docker`, `podman` or another compatible container engine. 39 | 40 | Run `just setup` to automatically set up the required dependencies in a virtual environment. 41 | 42 | ### Recipes 43 | 44 | - **`just setup`**: Sets up the Python virtual environment, installs the project locally in editable mode, and installs required dependencies for building packages. 45 | 46 | - **`just test [path='./']`**: Runs tests in the specified directory (default: `tests/`). 47 | 48 | - **`just build`**: Builds a Python wheel package for the project. 49 | 50 | - **`just build-container`**: Builds a container image for the project using the container engine (`podman` by default). Extracts the build artifacts into the `dist_container/` directory. 51 | 52 | - **`just clean`**: Cleans up build artifacts, including the `dist/`, `dist_container/`, and `build/` directories, and any `.egg-info` files. 53 | 54 | ## Documentation 55 | 56 | For detailed usage examples and understanding of the library's functionality, please refer to the following resources: 57 | 58 | - **Test Cases**: Explore the `tests/` directory for a variety of test cases that demonstrate how to use the library's features in practical scenarios. These tests provide concrete examples of encrypting, performing operations on encrypted data, and decrypting results. 59 | 60 | - **Docstrings**: The codebase includes docstrings that document functions and classes. To view the docstrings interactively, use the Python REPL or your preferred IDE to inspect functions and classes. 61 | 62 | The documentation is an ongoing effort and will continue to evolve over time. However, the codebase has been thoughtfully designed with clarity and usability in mind, ensuring that it is intuitive and straightforward for developers to work with, even in the absence of instructions. 63 | 64 | We encourage users to contribute to improving both the documentation and test cases to enhance clarity and usability for the community. 65 | 66 | ## License 67 | 68 | Please check the [License file](LICENSE.md). 69 | 70 | ## Support 71 | 72 | For issues or questions, please open an issue on [GitHub](https://github.com/Vaultree/venum-python-backend) or contact us at [our support page](https://support.vaultree.com). 73 | -------------------------------------------------------------------------------- /venum/rns.py: -------------------------------------------------------------------------------- 1 | from .logging import logger 2 | 3 | import math 4 | 5 | 6 | class RnsBasis: 7 | """ 8 | Class representing a Residue Number System basis 9 | 10 | Attributes: 11 | - moduli (iterable): Array of moduli for the RNS representation 12 | """ 13 | 14 | def __init__(self, moduli): 15 | RnsBasis.ensure_coprime_moduli(moduli) 16 | logger.debug(f"New RnsBasis: {moduli}") 17 | self.moduli = moduli 18 | 19 | @staticmethod 20 | def ensure_coprime_moduli(moduli): 21 | """ 22 | Check if the moduli are pairwise coprime 23 | 24 | Args: 25 | - moduli (iterable): Array of moduli for the RNS representation 26 | 27 | Raises: 28 | - ValueError: If the moduli are not pairwise coprime 29 | """ 30 | for i, m1 in enumerate(moduli): 31 | for m2 in moduli[i + 1:]: 32 | if math.gcd(m1, m2) != 1: 33 | raise ValueError("Moduli must be pairwise coprime") 34 | 35 | def __repr__(self): 36 | return f"RnsBasis(moduli={self.moduli})" 37 | 38 | def __len__(self): 39 | return len(self.moduli) 40 | 41 | def __eq__(self, other): 42 | """ 43 | Basis are equal if their moduli are equal 44 | """ 45 | return self.moduli == other.moduli 46 | 47 | def to_rns(self, value): 48 | """ 49 | Convert an integer to an RNS representation 50 | 51 | Args: 52 | - value (int): Integer to convert to RNS 53 | 54 | Returns: 55 | - Rns: RNS representation of the integer 56 | """ 57 | 58 | logger.debug(f"{value} -> {self}") 59 | residues = [value % m for m in self.moduli] 60 | return Rns(self, residues) 61 | 62 | 63 | class Rns: 64 | def __init__(self, basis, residues): 65 | """ 66 | Create a new RNS representation 67 | 68 | Args: 69 | - basis (RnsBasis): Basis for the RNS representation 70 | - residues (iterable): Residues for the RNS representation 71 | """ 72 | 73 | if len(basis) != len(residues): 74 | raise ValueError("Number of residues must match number of moduli") 75 | self._basis = basis 76 | self.residues = residues 77 | logger.debug(f"Created: {self}") 78 | 79 | @property 80 | def basis(self): 81 | return self._basis 82 | 83 | def __repr__(self): 84 | comps = ', '.join(f'{r} mod {m}' for (r, m) in zip( 85 | self.residues, self.basis.moduli)) 86 | return f"Rns({comps})" 87 | 88 | def coeffwise_op(self, other, op): 89 | """ 90 | Perform a coefficient-wise operation on two RNS representations 91 | 92 | Args: 93 | - other (Rns): Other RNS representation 94 | - op (function): Operation to perform on the residues 95 | 96 | Returns: 97 | - Rns: Result of the operation 98 | 99 | Raises: 100 | - ValueError: If the bases of the two RNS representations are not equal 101 | """ 102 | 103 | if not (isinstance(other, Rns) and self.basis == other.basis): 104 | raise ValueError(f"Incompatible basis: {self.basis}" 105 | f" != {other.basis}") 106 | logger.debug(f"Performing operation {op} on {self} and {other}") 107 | residues = [op(a, b) % m for a, b, m in zip( 108 | self.residues, other.residues, self.basis.moduli)] 109 | return Rns(self.basis, residues) 110 | 111 | def __getitem__(self, key): 112 | return self.residues[key] 113 | 114 | def __add__(self, other): 115 | return self.coeffwise_op(other, lambda a, b: a + b) 116 | 117 | def __sub__(self, other): 118 | return self.coeffwise_op(other, lambda a, b: a - b) 119 | 120 | def __mul__(self, other): 121 | return self.coeffwise_op(other, lambda a, b: a * b) 122 | 123 | def to_int(self): 124 | """ 125 | Convert the RNS representation to an integer 126 | 127 | Returns: 128 | int: Integer representation of the RNS representation 129 | """ 130 | 131 | M = math.prod(self.basis.moduli) 132 | total = 0 133 | for mi, ai in zip(self.basis.moduli, self.residues): 134 | Mi = M // mi 135 | inv = pow(int(Mi), -1, int(mi)) 136 | total += ai * Mi * inv 137 | result = total % M 138 | logger.debug(f"{self} -> {result}") 139 | return result 140 | -------------------------------------------------------------------------------- /venum/key.py: -------------------------------------------------------------------------------- 1 | from .glwe import GlweDistribution, GlweSample 2 | from .logging import logger 3 | 4 | from sympy import Poly 5 | 6 | import math 7 | from typing import Iterable, Tuple 8 | 9 | 10 | class SecretKey: 11 | """ 12 | A secret key for the scheme. 13 | 14 | Attributes: 15 | - dist: The GLWE distribution used to generate the secret key. 16 | - secret_poly: a secret polynomial. 17 | """ 18 | 19 | def __init__(self, dist: GlweDistribution, secret_poly: Poly): 20 | self._dist = dist 21 | self.secret_poly = secret_poly 22 | 23 | def __repr__(self): 24 | return f'SecretKey({self.secret_poly})' 25 | 26 | @classmethod 27 | def rand(cls, dist: GlweDistribution, modulus=None) -> 'SecretKey': 28 | """ 29 | Generates a random secret key. 30 | 31 | Args: 32 | - dist: A GLWE distribution to use. 33 | - modulus: The modulus to use for the secret key. If None, the modulus 34 | of the distribution is used. 35 | 36 | Returns: 37 | - A secret key. 38 | """ 39 | 40 | secret = dist.sample_polynomial(modulus) 41 | logger.debug(f"Generating random secret key from: {secret}") 42 | return cls(dist, secret) 43 | 44 | @property 45 | def dist(self): 46 | return self._dist 47 | 48 | 49 | class PublicKey: 50 | """ 51 | A public key for the scheme. 52 | 53 | Attributes: 54 | - glwe_sample: A GLWE sample representing the public key. 55 | """ 56 | 57 | def __init__(self, glwe_sample: GlweSample): 58 | self.glwe_sample = glwe_sample 59 | 60 | def __repr__(self): 61 | return f'PublicKey(mask={self.glwe_sample.mask}, ' 62 | f'body={self.glwe_sample.body})' 63 | 64 | @classmethod 65 | def from_secret_key(cls, secret_key: SecretKey) -> 'PublicKey': 66 | """ 67 | Generates a public key from a secret key. 68 | 69 | Args: 70 | - secret_key: The secret key to derive the public key from. 71 | 72 | Returns: 73 | - A public key. 74 | """ 75 | 76 | sample = secret_key.dist.sample_zero_secret(secret_key.secret_poly) 77 | logger.debug(f"Generating public key with sample: {sample}") 78 | return cls(sample) 79 | 80 | 81 | def gen_key_pair(dist: GlweDistribution, 82 | modulus=None) -> Tuple[SecretKey, PublicKey]: 83 | """ 84 | Generates a key pair. 85 | 86 | Args: 87 | - dist: The GLWE distribution to use. 88 | - modulus: The modulus to use for the secret key. If None, the modulus of 89 | the distribution is used. 90 | 91 | Returns: 92 | - A tuple (sk, pk) where sk is the secret key and pk is the public key. 93 | """ 94 | 95 | sk = SecretKey.rand(dist, modulus) 96 | pk = PublicKey.from_secret_key(sk) 97 | return sk, pk 98 | 99 | 100 | class RelinKey: 101 | """ 102 | A relinearization key for the scheme. Used to normalize ciphertexts. 103 | 104 | Attributes: 105 | - aux_keys: A list of auxiliary keys as described in the reference paper. 106 | - base: The base/radix used to generate the auxiliary keys. 107 | """ 108 | 109 | def __init__(self, aux_keys: Iterable[GlweSample], base: int): 110 | self.aux_keys = aux_keys 111 | self.base = base 112 | 113 | @staticmethod 114 | def _compute_aux_keys(sk: SecretKey, base: int) -> Iterable[GlweSample]: 115 | digit_count = math.log(sk.dist.params.ciphertext_modulus, base) 116 | digit_count = math.ceil(digit_count) 117 | aux_keys = [] 118 | sk2 = sk.secret_poly ** 2 119 | for i in range(digit_count): 120 | mask = sk.dist.sample_mask() 121 | crt_noise = (sk.dist.sample_crt_noise() 122 | .set_domain(sk.dist.cipher_ring)) 123 | masked_secret = mask * sk.secret_poly 124 | masked_secret = masked_secret % sk.dist.poly_modulus 125 | noisy_secret = masked_secret + crt_noise 126 | noisy_secret = noisy_secret % sk.dist.poly_modulus 127 | message = base ** i * sk2 128 | message = message % sk.dist.poly_modulus 129 | body = (noisy_secret + message) % sk.dist.poly_modulus 130 | aux_keys.append(GlweSample(mask=-mask, body=body)) 131 | return aux_keys 132 | 133 | @classmethod 134 | def from_secret_key(cls, secret_key: SecretKey, 135 | base: int = 2) -> 'RelinKey': 136 | """ 137 | Generates a relinearization key from a secret key. 138 | 139 | Args: 140 | - secret_key: The secret key to derive the relinearization key from. 141 | - base: The base to use for the relinearization key. Defaults to 2. 142 | 143 | Returns: 144 | - A relinearization key. 145 | """ 146 | 147 | aux_keys = cls._compute_aux_keys(secret_key, base) 148 | return cls(aux_keys, base) 149 | 150 | def digit_count(self): 151 | """ 152 | The number of parts in the relinearization key decomposition. 153 | """ 154 | 155 | return len(self.aux_keys) 156 | -------------------------------------------------------------------------------- /venum/glwe.py: -------------------------------------------------------------------------------- 1 | from .rns import RnsBasis 2 | from .crt import CrtEncoder 3 | from .logging import logger 4 | 5 | from sympy import Poly, GF 6 | from sympy.abc import x 7 | from sympy.polys.specialpolys import random_poly 8 | 9 | import random 10 | from dataclasses import dataclass 11 | 12 | 13 | @dataclass 14 | class EncryptionParameters: 15 | """ 16 | Parameters for the encryption scheme. 17 | """ 18 | 19 | dimension: int 20 | ciphertext_modulus: int 21 | plaintext_modulus: int 22 | noise_modulus: int 23 | seed: int = None 24 | 25 | def __post_init__(self): 26 | if (self.plaintext_modulus * self.noise_modulus 27 | >= self.ciphertext_modulus): 28 | raise ValueError( 29 | 'Invalid parameters: plaintext_modulus * noise_modulus ' 30 | '>= ciphertext_modulus') 31 | 32 | 33 | class GlweSample: 34 | """ 35 | A sample from the GLWE distribution. 36 | 37 | The sample is a pair of polynomials (mask, body). 38 | """ 39 | 40 | def __init__(self, mask, body): 41 | self.mask = mask 42 | self.body = body 43 | 44 | @staticmethod 45 | def _compute_mask(mask, noise, u, poly_modulus): 46 | new_mask = mask * u + noise 47 | new_mask = new_mask % poly_modulus 48 | return new_mask 49 | 50 | @staticmethod 51 | def _compute_body(body, noise, message, u, poly_modulus): 52 | new_body = body * u + message + noise 53 | new_body = new_body % poly_modulus 54 | return new_body 55 | 56 | @classmethod 57 | def compute_sample(cls, mask, mask_noise, body, body_noise, 58 | message, u, poly_modulus): 59 | """ 60 | Compute a new sample from the given parameters. The new sample 61 | corresponds to an encryption of the given message. 62 | """ 63 | 64 | new_mask = cls._compute_mask(mask, mask_noise, u, 65 | poly_modulus) 66 | new_body = cls._compute_body(body, body_noise, message, u, 67 | poly_modulus) 68 | return cls(mask=new_mask, body=new_body) 69 | 70 | @classmethod 71 | def _compute_zero_sample( 72 | cls, mask: Poly, secret: Poly, 73 | crt_noise: Poly, poly_modulus: Poly): 74 | body = (mask * secret + 75 | crt_noise.set_domain(poly_modulus.domain)) % poly_modulus 76 | return cls(mask=-mask, body=body) 77 | 78 | def __repr__(self): 79 | return f'GlweSample(mask={self.mask}, body={self.body})' 80 | 81 | 82 | class GlweDistribution: 83 | def __init__(self, params: EncryptionParameters): 84 | """ 85 | Initialize the GLWE distribution with the given parameters. 86 | 87 | Args: 88 | - params: the encryption parameters. 89 | """ 90 | 91 | if params.seed is not None: 92 | random.seed(params.seed) 93 | logger.warning(f"Setting random seed to {params.seed}") 94 | self.params = params 95 | self.plaintext_ring = GF(params.plaintext_modulus, symmetric=False) 96 | self.cipher_ring = GF(params.ciphertext_modulus, symmetric=False) 97 | self.poly_modulus = Poly( 98 | x ** params.dimension + 1, x, domain=self.cipher_ring) 99 | crt_basis = RnsBasis( 100 | [self.params.plaintext_modulus, self.params.noise_modulus]) 101 | self.crt_encoder = CrtEncoder(crt_basis, self.plaintext_ring) 102 | 103 | def sample_polynomial(self, modulus=None): 104 | """ 105 | Sample a polynomial with coefficients in the given modulus. 106 | 107 | Args: 108 | - modulus: the modulus for the coefficients. If None, the 109 | ciphertext modulus is used. 110 | 111 | Returns: 112 | - a polynomial with coefficients in the given modulus. 113 | """ 114 | 115 | modulus = modulus or self.params.ciphertext_modulus 116 | degree = self.params.dimension - 1 117 | return random_poly( 118 | x, 119 | n=degree, 120 | inf=0, 121 | sup=modulus - 1 122 | ).as_poly(domain=self.cipher_ring) 123 | 124 | def sample_mask(self): 125 | """ 126 | Sample a mask polynomial. 127 | 128 | Returns: 129 | 130 | - a polynomial with coefficients in the ciphertext modulus. 131 | """ 132 | 133 | return self.sample_polynomial() 134 | 135 | def sample_noise(self): 136 | """ 137 | Sample a noise polynomial. 138 | 139 | Returns: 140 | 141 | - a polynomial with coefficients in the noise modulus. 142 | """ 143 | 144 | # FIX: for security reasons, noise should not be sampled from 145 | # a uniform distribution. 146 | return self.sample_polynomial(self.params.noise_modulus) 147 | 148 | def sample_crt_noise(self): 149 | """ 150 | Sample a CRT noise polynomial. 151 | 152 | Returns: 153 | 154 | - a CRT-encoded noise polynomial. 155 | """ 156 | 157 | noise = self.sample_noise() 158 | logger.debug(f"Sampled CRT noise: {noise}") 159 | crt_noise = self.crt_encoder.encode_pure_noise(noise) 160 | logger.debug(f"CRT noise: {crt_noise}") 161 | return crt_noise 162 | 163 | def sample_zero_secret(self, secret: Poly): 164 | """ 165 | Produces a random GLWE sample corresponding to an encryption of 166 | zero message. 167 | 168 | Args: 169 | - secret: the secret polynomial to use for the encryption. 170 | 171 | Returns: 172 | - a GLWE sample corresponding to an encryption of zero. 173 | """ 174 | 175 | mask = self.sample_mask() 176 | crt_noise = self.sample_crt_noise() 177 | return GlweSample._compute_zero_sample( 178 | mask, secret, crt_noise, self.poly_modulus) 179 | -------------------------------------------------------------------------------- /venum/encryption.py: -------------------------------------------------------------------------------- 1 | from .logging import logger 2 | from .glwe import GlweSample, GlweDistribution 3 | from .key import SecretKey, PublicKey, RelinKey 4 | from .numeric import radix_decompose_poly 5 | 6 | from sympy import Poly 7 | from sympy.abc import x 8 | 9 | from typing import Iterable 10 | 11 | 12 | class Cipher: 13 | """ 14 | A class representing a GLWE ciphertext. 15 | 16 | Attributes: 17 | - glwe_sample: A GlweSample object representing the ciphertext. 18 | """ 19 | 20 | def __init__(self, glwe_sample): 21 | self.glwe_sample = glwe_sample 22 | 23 | def __repr__(self): 24 | return f'Cipher(mask={self.glwe_sample.mask}, ' 25 | f'body={self.glwe_sample.body})' 26 | 27 | 28 | class Encryptor: 29 | """ 30 | A class for handling encryption and decryption of messages. 31 | """ 32 | 33 | def __init__(self, dist: GlweDistribution, plaintext_encoder): 34 | """ 35 | Initializes an Encryptor object. 36 | 37 | Args: 38 | - dist: A GlweDistribution object representing the distribution 39 | used for encryption. 40 | - plaintext_encoder: An object that encodes and decodes messages 41 | according to the `venum.plaintext_encoding.Encoder` interface. 42 | """ 43 | 44 | self._dist = dist 45 | self.plaintext_encoder = plaintext_encoder 46 | 47 | @property 48 | def dist(self): 49 | return self._dist 50 | 51 | def encrypt(self, pk: PublicKey, message: Iterable[int], 52 | plaintext_encoder=None) -> Cipher: 53 | """ 54 | Encrypts a message. 55 | 56 | Args: 57 | - pk: A PublicKey object representing the public key. 58 | - message: An iterable of integers representing the message. 59 | - plaintext_encoder: An object that encodes and decodes messages 60 | according to the `venum.plaintext_encoding.Encoder` interface. 61 | If None, the default encoder is used. 62 | 63 | Returns: 64 | - A Cipher object representing the encrypted message. 65 | """ 66 | 67 | logger.debug(f'Encrypting message: {message}') 68 | 69 | plaintext_encoder = plaintext_encoder or self.plaintext_encoder 70 | 71 | message = plaintext_encoder.encode(message) 72 | logger.debug(f'encoded message: {message}') 73 | 74 | crt_message = self.dist.crt_encoder.encode_pure_message( 75 | message).set_domain(self.dist.cipher_ring) 76 | logger.debug(f'crt_message: {crt_message}') 77 | 78 | crt_noise1 = (self.dist.sample_crt_noise() 79 | .set_domain(self.dist.cipher_ring)) 80 | crt_noise2 = (self.dist.sample_crt_noise() 81 | .set_domain(self.dist.cipher_ring)) 82 | 83 | u = self.dist.sample_polynomial(modulus=2) 84 | logger.debug(f'sampled u: {u}') 85 | 86 | logger.debug(f"using public key: {pk.glwe_sample}") 87 | sample = GlweSample.compute_sample( 88 | mask=pk.glwe_sample.mask, 89 | mask_noise=crt_noise2, 90 | body=pk.glwe_sample.body, 91 | body_noise=crt_noise1, 92 | message=crt_message, 93 | u=u, 94 | poly_modulus=self.dist.poly_modulus, 95 | ) 96 | return Cipher(sample) 97 | 98 | def decrypt(self, sk: SecretKey, cipher: Cipher) -> Iterable[int]: 99 | """ 100 | Decrypts a ciphertext. 101 | 102 | Args: 103 | - sk: A SecretKey object representing the secret key. 104 | - cipher: A Cipher object representing the ciphertext. 105 | 106 | Returns: 107 | - An iterable of integers representing the decrypted message. 108 | """ 109 | 110 | logger.debug(f"{cipher}") 111 | cipher_mask = cipher.glwe_sample.mask 112 | cipher_body = cipher.glwe_sample.body 113 | 114 | crt_message = (cipher_body + cipher_mask * sk.secret_poly) 115 | crt_message = crt_message % self.dist.poly_modulus 116 | logger.debug(f"{crt_message}") 117 | noisy_message = self.dist.crt_encoder.decode(crt_message) 118 | logger.debug(f"{noisy_message}") 119 | 120 | message_poly = Poly.from_list([rns[0] for rns in noisy_message], 121 | x, domain=self.dist.plaintext_ring) 122 | 123 | logger.debug(f"{message_poly}") 124 | return self.plaintext_encoder.decode(message_poly) 125 | 126 | 127 | class Rank2Cipher: 128 | """ 129 | A class representing a rank-2 ciphertext, representing a non-normalized 130 | Cipher containing squared terms. Usually produced as an intermediate step 131 | during homomorphic multiplication. 132 | 133 | Attributes: 134 | - constant: A Poly object representing the constant term over the secret. 135 | - linear: A Poly object representing the linear term over the secret. 136 | - quadratic: A Poly object representing the quadratic term over the secret. 137 | """ 138 | 139 | def __init__(self, constant: Poly, linear: Poly, quadratic: Poly): 140 | self.constant = constant 141 | self.linear = linear 142 | self.quadratic = quadratic 143 | 144 | def relinearize(self, relin_key: RelinKey, poly_modulus) -> Cipher: 145 | """ 146 | Relinearizes the rank-2 ciphertext into a normalized Cipher. 147 | 148 | Args: 149 | - relin_key: A RelinKey object representing the relinearization key. 150 | - poly_modulus: A Poly object representing the polynomial modulus. 151 | 152 | Returns: 153 | - A Cipher object representing the relinearized ciphertext. 154 | """ 155 | 156 | cipher_ring = poly_modulus.domain 157 | quad_decomposed = radix_decompose_poly( 158 | poly=self.quadratic, 159 | radix=relin_key.base, 160 | num_components=relin_key.digit_count(), 161 | domain=cipher_ring 162 | ) 163 | mask = Poly([0], x, domain=cipher_ring) 164 | body = Poly([0], x, domain=cipher_ring) 165 | for aux_key, component in zip(relin_key.aux_keys, 166 | quad_decomposed): 167 | mask += aux_key.mask * component 168 | mask = mask % poly_modulus 169 | body += aux_key.body * component 170 | body = body % poly_modulus 171 | mask += self.linear 172 | body += self.constant 173 | mask = mask % poly_modulus 174 | body = body % poly_modulus 175 | return Cipher(GlweSample(mask=mask, body=body)) 176 | --------------------------------------------------------------------------------