def example_bfv_basics(): print("Example: BFV Basics") #In this example, we demonstrate performing simple computations (a polynomial #evaluation) on encrypted integers using the BFV encryption scheme. # #The first task is to set up an instance of the EncryptionParameters class. #It is critical to understand how the different parameters behave, how they #affect the encryption scheme, performance, and the security level. There are #three encryption parameters that are necessary to set: # # - poly_modulus_degree (degree of polynomial modulus); # - coeff_modulus ([ciphertext] coefficient modulus); # - plain_modulus (plaintext modulus; only for the BFV scheme). # #The BFV scheme cannot perform arbitrary computations on encrypted data. #Instead, each ciphertext has a specific quantity called the `invariant noise #budget' -- or `noise budget' for short -- measured in bits. The noise budget #in a freshly encrypted ciphertext (initial noise budget) is determined by #the encryption parameters. Homomorphic operations consume the noise budget #at a rate also determined by the encryption parameters. In BFV the two basic #operations allowed on encrypted data are additions and multiplications, of #which additions can generally be thought of as being nearly free in terms of #noise budget consumption compared to multiplications. Since noise budget #consumption compounds in sequential multiplications, the most significant #factor in choosing appropriate encryption parameters is the multiplicative #depth of the arithmetic circuit that the user wants to evaluate on encrypted #data. Once the noise budget of a ciphertext reaches zero it becomes too #corrupted to be decrypted. Thus, it is essential to choose the parameters to #be large enough to support the desired computation; otherwise the result is #impossible to make sense of even with the secret key. parms = EncryptionParameters(scheme_type.BFV) #The first parameter we set is the degree of the `polynomial modulus'. This #must be a positive power of 2, representing the degree of a power-of-two #cyclotomic polynomial; it is not necessary to understand what this means. # #Larger poly_modulus_degree makes ciphertext sizes larger and all operations #slower, but enables more complicated encrypted computations. Recommended #values are 1024, 2048, 4096, 8192, 16384, 32768, but it is also possible #to go beyond this range. # #In this example we use a relatively small polynomial modulus. Anything #smaller than this will enable only very restricted encrypted computations. poly_modulus_degree = 4096 parms.set_poly_modulus_degree(poly_modulus_degree) #Next we set the [ciphertext] `coefficient modulus' (coeff_modulus). This #parameter is a large integer, which is a product of distinct prime numbers, #each up to 60 bits in size. It is represented as a vector of these prime #numbers, each represented by an instance of the SmallModulus class. The #bit-length of coeff_modulus means the sum of the bit-lengths of its prime #factors. # #A larger coeff_modulus implies a larger noise budget, hence more encrypted #computation capabilities. However, an upper bound for the total bit-length #of the coeff_modulus is determined by the poly_modulus_degree, as follows: # # +----------------------------------------------------+ # | poly_modulus_degree | max coeff_modulus bit-length | # +---------------------+------------------------------+ # | 1024 | 27 | # | 2048 | 54 | # | 4096 | 109 | # | 8192 | 218 | # | 16384 | 438 | # | 32768 | 881 | # +---------------------+------------------------------+ # #These numbers can also be found in native/src/seal/util/hestdparms.h encoded #in the function SEAL_HE_STD_PARMS_128_TC, and can also be obtained from the #function # # CoeffModulus::MaxBitCount(poly_modulus_degree). # #For example, if poly_modulus_degree is 4096, the coeff_modulus could consist #of three 36-bit primes (108 bits). # #Microsoft SEAL comes with helper functions for selecting the coeff_modulus. #For new users the easiest way is to simply use # # CoeffModulus::BFVDefault(poly_modulus_degree), # #which returns std::vector<SmallModulus> consisting of a generally good choice #for the given poly_modulus_degree. parms.set_coeff_modulus(CoeffModulus.BFVDefault(poly_modulus_degree)) #The plaintext modulus can be any positive integer, even though here we take #it to be a power of two. In fact, in many cases one might instead want it #to be a prime number; we will see this in later examples. The plaintext #modulus determines the size of the plaintext data type and the consumption #of noise budget in multiplications. Thus, it is essential to try to keep the #plaintext data type as small as possible for best performance. The noise #budget in a freshly encrypted ciphertext is # # ~ log2(coeff_modulus/plain_modulus) (bits) # #and the noise budget consumption in a homomorphic multiplication is of the #form log2(plain_modulus) + (other terms). # #The plaintext modulus is specific to the BFV scheme, and cannot be set when #using the CKKS scheme. parms.set_plain_modulus(1024) #Now that all parameters are set, we are ready to construct a SEALContext #object. This is a heavy class that checks the validity and properties of the #parameters we just set. context = SEALContext.Create(parms) #Print the parameters that we have chosen. print("Set encryption parameters and print") print_parameters(context) print("~~~~~~ A naive way to calculate 4(x^2+1)(x+1)^2. ~~~~~~") #The encryption schemes in Microsoft SEAL are public key encryption schemes. #For users unfamiliar with this terminology, a public key encryption scheme #has a separate public key for encrypting data, and a separate secret key for #decrypting data. This way multiple parties can encrypt data using the same #shared public key, but only the proper recipient of the data can decrypt it #with the secret key. # #We are now ready to generate the secret and public keys. For this purpose #we need an instance of the KeyGenerator class. Constructing a KeyGenerator #automatically generates the public and secret key, which can immediately be #read to local variables. keygen = KeyGenerator(context) public_key = keygen.public_key() secret_key = keygen.secret_key() #To be able to encrypt we need to construct an instance of Encryptor. Note #that the Encryptor only requires the public key, as expected. encryptor = Encryptor(context, public_key) #Computations on the ciphertexts are performed with the Evaluator class. In #a real use-case the Evaluator would not be constructed by the same party #that holds the secret key. evaluator = Evaluator(context) #We will of course want to decrypt our results to verify that everything worked, #so we need to also construct an instance of Decryptor. Note that the Decryptor #requires the secret key. decryptor = Decryptor(context, secret_key) #As an example, we evaluate the degree 4 polynomial # # 4x^4 + 8x^3 + 8x^2 + 8x + 4 # #over an encrypted x = 6. The coefficients of the polynomial can be considered #as plaintext inputs, as we will see below. The computation is done modulo the #plain_modulus 1024. # #While this examples is simple and easy to understand, it does not have much #practical value. In later examples we will demonstrate how to compute more #efficiently on encrypted integers and real or complex numbers. # #Plaintexts in the BFV scheme are polynomials of degree less than the degree #of the polynomial modulus, and coefficients integers modulo the plaintext #modulus. For readers with background in ring theory, the plaintext space is #the polynomial quotient ring Z_T[X]/(X^N + 1), where N is poly_modulus_degree #and T is plain_modulus. # #To get started, we create a plaintext containing the constant 6. For the #plaintext element we use a constructor that takes the desired polynomial as #a string with coefficients represented as hexadecimal numbers. x = 6 x_plain = Plaintext(str(x)) print("Express x = {} as a plaintext polynomial 0x{}.".format( x, x_plain.to_string())) #We then encrypt the plaintext, producing a ciphertext. x_encrypted = Ciphertext() print("Encrypt x_plain to x_encrypted.") encryptor.encrypt(x_plain, x_encrypted) #In Microsoft SEAL, a valid ciphertext consists of two or more polynomials #whose coefficients are integers modulo the product of the primes in the #coeff_modulus. The number of polynomials in a ciphertext is called its `size' #and is given by Ciphertext::size(). A freshly encrypted ciphertext always #has size 2. print(" + size of freshly encrypted x: {}".format(x_encrypted.size())) #There is plenty of noise budget left in this freshly encrypted ciphertext. print(" + noise budget in freshly encrypted x: {} bits".format( decryptor.invariant_noise_budget(x_encrypted))) #We decrypt the ciphertext and print the resulting plaintext in order to #demonstrate correctness of the encryption. x_decrypted = Plaintext() decryptor.decrypt(x_encrypted, x_decrypted) print(" + decryption of x_encrypted: 0x{} ...... Correct.".format( x_decrypted.to_string())) #When using Microsoft SEAL, it is typically advantageous to compute in a way #that minimizes the longest chain of sequential multiplications. In other #words, encrypted computations are best evaluated in a way that minimizes #the multiplicative depth of the computation, because the total noise budget #consumption is proportional to the multiplicative depth. For example, for #our example computation it is advantageous to factorize the polynomial as # # 4x^4 + 8x^3 + 8x^2 + 8x + 4 = 4(x + 1)^2 * (x^2 + 1) # #to obtain a simple depth 2 representation. Thus, we compute (x + 1)^2 and #(x^2 + 1) separately, before multiplying them, and multiplying by 4. # #First, we compute x^2 and add a plaintext "1". We can clearly see from the #print-out that multiplication has consumed a lot of noise budget. The user #can vary the plain_modulus parameter to see its effect on the rate of noise #budget consumption. print("Compute x_sq_plus_one (x^2+1).") x_sq_plus_one = Ciphertext() evaluator.square(x_encrypted, x_sq_plus_one) plain_one = Plaintext("1") evaluator.add_plain_inplace(x_sq_plus_one, plain_one) #Encrypted multiplication results in the output ciphertext growing in size. #More precisely, if the input ciphertexts have size M and N, then the output #ciphertext after homomorphic multiplication will have size M+N-1. In this #case we perform a squaring, and observe both size growth and noise budget #consumption. print(" + size of x_sq_plus_one: {}".format(x_sq_plus_one.size())) print(" + noise budget in x_sq_plus_one: {} bits".format( decryptor.invariant_noise_budget(x_sq_plus_one))) #Even though the size has grown, decryption works as usual as long as noise #budget has not reached 0. decrypted_result = Plaintext() decryptor.decrypt(x_sq_plus_one, decrypted_result) print(" + decryption of x_sq_plus_one: 0x{} ...... Correct.".format( decrypted_result.to_string())) #Next, we compute (x + 1)^2. print("Compute x_plus_one_sq ((x+1)^2).") x_plus_one_sq = Ciphertext() evaluator.add_plain(x_encrypted, plain_one, x_plus_one_sq) evaluator.square_inplace(x_plus_one_sq) print(" + size of x_plus_one_sq: {}".format(x_plus_one_sq.size())) print(" + noise budget in x_plus_one_sq: {} bits".format( decryptor.invariant_noise_budget(x_plus_one_sq))) decryptor.decrypt(x_plus_one_sq, decrypted_result) print(" + decryption of x_plus_one_sq: 0x{} ...... Correct.".format( decrypted_result.to_string())) #Finally, we multiply (x^2 + 1) * (x + 1)^2 * 4. print("Compute encrypted_result (4(x^2+1)(x+1)^2).") encrypted_result = Ciphertext() plain_four = Plaintext("4") evaluator.multiply_plain_inplace(x_sq_plus_one, plain_four) evaluator.multiply(x_sq_plus_one, x_plus_one_sq, encrypted_result) print(" + size of encrypted_result: {}".format(encrypted_result.size())) print(" + noise budget in encrypted_result: {} bits".format( decryptor.invariant_noise_budget(encrypted_result))) print("NOTE: Decryption can be incorrect if noise budget is zero.") print("~~~~~~ A better way to calculate 4(x^2+1)(x+1)^2. ~~~~~~") #Noise budget has reached 0, which means that decryption cannot be expected #to give the correct result. This is because both ciphertexts x_sq_plus_one #and x_plus_one_sq consist of 3 polynomials due to the previous squaring #operations, and homomorphic operations on large ciphertexts consume much more #noise budget than computations on small ciphertexts. Computing on smaller #ciphertexts is also computationally significantly cheaper. #`Relinearization' is an operation that reduces the size of a ciphertext after #multiplication back to the initial size, 2. Thus, relinearizing one or both #input ciphertexts before the next multiplication can have a huge positive #impact on both noise growth and performance, even though relinearization has #a significant computational cost itself. It is only possible to relinearize #size 3 ciphertexts down to size 2, so often the user would want to relinearize #after each multiplication to keep the ciphertext sizes at 2. #Relinearization requires special `relinearization keys', which can be thought #of as a kind of public key. Relinearization keys can easily be created with #the KeyGenerator. #Relinearization is used similarly in both the BFV and the CKKS schemes, but #in this example we continue using BFV. We repeat our computation from before, #but this time relinearize after every multiplication. #We use KeyGenerator::relin_keys() to create relinearization keys. print("Generate relinearization keys.") relin_keys = keygen.relin_keys() #We now repeat the computation relinearizing after each multiplication. print("Compute and relinearize x_squared (x^2),") print("then compute x_sq_plus_one (x^2+1)") x_squared = Ciphertext() evaluator.square(x_encrypted, x_squared) print(" + size of x_squared: {}".format(x_squared.size())) evaluator.relinearize_inplace(x_squared, relin_keys) print(" + size of x_squared (after relinearization): {}".format( x_squared.size())) evaluator.add_plain(x_squared, plain_one, x_sq_plus_one) print(" + noise budget in x_sq_plus_one: {} bits".format( decryptor.invariant_noise_budget(x_sq_plus_one))) decryptor.decrypt(x_sq_plus_one, decrypted_result) print(" + decryption of x_sq_plus_one: 0x{} ...... Correct.".format( decrypted_result.to_string())) x_plus_one = Ciphertext() print("Compute x_plus_one (x+1),") print("then compute and relinearize x_plus_one_sq ((x+1)^2).") evaluator.add_plain(x_encrypted, plain_one, x_plus_one) evaluator.square(x_plus_one, x_plus_one_sq) print(" + size of x_plus_one_sq: {}".format(x_plus_one_sq.size())) evaluator.relinearize_inplace(x_plus_one_sq, relin_keys) print(" + noise budget in x_plus_one_sq: {} bits".format( decryptor.invariant_noise_budget(x_plus_one_sq))) decryptor.decrypt(x_plus_one_sq, decrypted_result) print(" + decryption of x_plus_one_sq: 0x{} ...... Correct.".format( decrypted_result.to_string())) print("Compute and relinearize encrypted_result (4(x^2+1)(x+1)^2).") evaluator.multiply_plain_inplace(x_sq_plus_one, plain_four) evaluator.multiply(x_sq_plus_one, x_plus_one_sq, encrypted_result) print(" + size of encrypted_result: {}".format(encrypted_result.size())) evaluator.relinearize_inplace(encrypted_result, relin_keys) print(" + size of encrypted_result (after relinearization): {}".format( encrypted_result.size())) print(" + noise budget in encrypted_result: {} bits".format( decryptor.invariant_noise_budget(encrypted_result))) print("NOTE: Notice the increase in remaining noise budget.") #Relinearization clearly improved our noise consumption. We have still plenty #of noise budget left, so we can expect the correct answer when decrypting. print("Decrypt encrypted_result (4(x^2+1)(x+1)^2).") decryptor.decrypt(encrypted_result, decrypted_result) print(" + decryption of 4(x^2+1)(x+1)^2 = 0x{} ...... Correct.".format( decrypted_result.to_string()))
def example_basics_ii(): print_example_banner("Example: Basics II") # In this example we explain what relinearization is, how to use it, and how # it affects noise budget consumption. # First we set the parameters, create a SEALContext, and generate the public # and secret keys. We use slightly larger parameters than be fore to be able # to do more homomorphic multiplications. parms = EncryptionParameters() parms.set_poly_modulus("1x^8192 + 1") # The default coefficient modulus consists of the following primes: # 0x7fffffffba0001, # 0x7fffffffaa0001, # 0x7fffffff7e0001, # 0x3fffffffd60001. # The total size is 219 bits. parms.set_coeff_modulus(seal.coeff_modulus_128(8192)) parms.set_plain_modulus(1 << 10) context = SEALContext(parms) print_parameters(context) keygen = KeyGenerator(context) public_key = keygen.public_key() secret_key = keygen.secret_key() # We also set up an Encryptor, Evaluator, and Decryptor here. We will # encrypt polynomials directly in this example, so there is no need for # an encoder. encryptor = Encryptor(context, public_key) evaluator = Evaluator(context) decryptor = Decryptor(context, secret_key) # There are actually two more types of keys in SEAL: `evaluation keys' and # `Galois keys'. Here we will discuss evaluation keys, and Galois keys will # be discussed later in example_batching(). # In SEAL, a valid ciphertext consists of two or more polynomials with # coefficients integers modulo the product of the primes in coeff_modulus. # The current size of a ciphertext can be found using Ciphertext::size(). # A freshly encrypted ciphertext always has size 2. #plain1 = Plaintext("1x^2 + 2x^1 + 3") plain1 = Plaintext("1x^2 + 2x^1 + 3") encrypted = Ciphertext() print("") print("Encrypting " + plain1.to_string() + ": ") encryptor.encrypt(plain1, encrypted) print("Done") print("Size of a fresh encryption: " + (str)(encrypted.size())) print("Noise budget in fresh encryption: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") # Homomorphic multiplication results in the output ciphertext growing in size. # More precisely, if the input ciphertexts have size M and N, then the output # ciphertext after homomorphic multiplication will have size M+N-1. In this # case we square encrypted twice to observe this growth (also observe noise # budget consumption). evaluator.square(encrypted) print("Size after squaring: " + (str)(encrypted.size())) print("Noise budget after squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") plain2 = Plaintext() decryptor.decrypt(encrypted, plain2) print("Second power: " + plain2.to_string()) evaluator.square(encrypted) print("Size after squaring: " + (str)(encrypted.size())) print("Noise budget after squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") # It does not matter that the size has grown -- decryption works as usual. # Observe from the print-out that the coefficients in the plaintext have # grown quite large. One more squaring would cause some of them to wrap # around plain_modulus (0x400), and as a result we would no longer obtain # the expected result as an integer-coefficient polynomial. We can fix this # problem to some extent by increasing plain_modulus. This would make sense, # since we still have plenty of noise budget left. plain2 = Plaintext() decryptor.decrypt(encrypted, plain2) print("Fourth power: " + plain2.to_string()) # The problem here is that homomorphic operations on large ciphertexts are # computationally much more costly than on small ciphertexts. Specifically, # homomorphic multiplication on input ciphertexts of size M and N will require # O(M*N) polynomial multiplications to be performed, and an addition will # require O(M+N) additions. Relinearization reduces the size of the ciphertexts # after multiplication back to the initial size (2). Thus, relinearizing one # or both inputs before the next multiplication, or e.g. before serializing the # ciphertexts, can have a huge positive impact on performance. # Another problem is that the noise budget consumption in multiplication is # bigger when the input ciphertexts sizes are bigger. In a complicated # computation the contribution of the sizes to the noise budget consumption # can actually become the dominant term. We will point this out again below # once we get to our example. # Relinearization itself has both a computational cost and a noise budget cost. # These both depend on a parameter called `decomposition bit count', which can # be any integer at least 1 [dbc_min()] and at most 60 [dbc_max()]. A large # decomposition bit count makes relinearization fast, but consumes more noise # budget. A small decomposition bit count can make relinearization slower, but # might not change the noise budget by any observable amount. # Relinearization requires a special type of key called `evaluation keys'. # These can be created by the KeyGenerator for any decomposition bit count. # To relinearize a ciphertext of size M >= 2 back to size 2, we actually need # M-2 evaluation keys. Attempting to relinearize a too large ciphertext with # too few evaluation keys will result in an exception being thrown. # We repeat our computation, but this time relinearize after both squarings. # Since our ciphertext never grows past size 3 (we relinearize after every # multiplication), it suffices to generate only one evaluation key. # First, we need to create evaluation keys. We use a decomposition bit count # of 16 here, which can be thought of as quite small. ev_keys16 = EvaluationKeys() # This function generates one single evaluation key. Another overload takes # the number of keys to be generated as an argument, but one is all we need # in this example (see above). keygen.generate_evaluation_keys(16, ev_keys16) print("") print("Encrypting " + plain1.to_string() + ": ") encryptor.encrypt(plain1, encrypted) print("Done") print("Size of a fresh encryption: " + (str)(encrypted.size())) print("Noise budget in fresh encryption: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.square(encrypted) print("Size after squaring: " + (str)(encrypted.size())) print("Noise budget after squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.relinearize(encrypted, ev_keys16) print("Size after relinearization: " + (str)(encrypted.size())) print("Noise budget after relinearizing (dbs = " + (str)(ev_keys16.decomposition_bit_count()) + "): " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.square(encrypted) print("Size after second squaring: " + (str)(encrypted.size()) + " bits") print("Noise budget after second squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.relinearize(encrypted, ev_keys16) print("Size after relinearization: " + (str)(encrypted.size())) print("Noise budget after relinearizing (dbs = " + (str)(ev_keys16.decomposition_bit_count()) + "): " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") decryptor.decrypt(encrypted, plain2) print("Fourth power: " + plain2.to_string()) # Of course the result is still the same, but this time we actually # used less of our noise budget. This is not surprising for two reasons: # - We used a very small decomposition bit count, which is why # relinearization itself did not consume the noise budget by any # observable amount; # - Since our ciphertext sizes remain small throughout the two # squarings, the noise budget consumption rate in multiplication # remains as small as possible. Recall from above that operations # on larger ciphertexts actually cause more noise growth. # To make matters even more clear, we repeat the computation a third time, # now using the largest possible decomposition bit count (60). We are not # measuring the time here, but relinearization with these evaluation keys # is significantly faster than with ev_keys16. ev_keys60 = EvaluationKeys() keygen.generate_evaluation_keys(seal.dbc_max(), ev_keys60) print("") print("Encrypting " + plain1.to_string() + ": ") encryptor.encrypt(plain1, encrypted) print("Done") print("Size of a fresh encryption: " + (str)(encrypted.size())) print("Noise budget in fresh encryption: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.square(encrypted) print("Size after squaring: " + (str)(encrypted.size())) print("Noise budget after squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.relinearize(encrypted, ev_keys60) print("Size after relinearization: " + (str)(encrypted.size())) print("Noise budget after relinearizing (dbc = " + (str)(ev_keys60.decomposition_bit_count()) + "): " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.square(encrypted) print("Size after second squaring: " + (str)(encrypted.size())) print("Noise budget after second squaring: " + (str)(decryptor.invariant_noise_budget) + " bits") evaluator.relinearize(encrypted, ev_keys60) print("Size after relinearization: " + (str)(encrypted.size())) print("Noise budget after relinearizing (dbc = " + (str)(ev_keys60.decomposition_bit_count()) + "): " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") decryptor.decrypt(encrypted, plain2) print("Fourth power: " + plain2.to_string()) # Observe from the print-out that we have now used significantly more of our # noise budget than in the two previous runs. This is again not surprising, # since the first relinearization chops off a huge part of the noise budget. # However, note that the second relinearization does not change the noise # budget by any observable amount. This is very important to understand when # optimal performance is desired: relinearization always drops the noise # budget from the maximum (freshly encrypted ciphertext) down to a fixed # amount depending on the encryption parameters and the decomposition bit # count. On the other hand, homomorphic multiplication always consumes the # noise budget from its current level. This is why the second relinearization # does not change the noise budget anymore: it is already consumed past the # fixed amount determinted by the decomposition bit count and the encryption # parameters. # We now perform a third squaring and observe an even further compounded # decrease in the noise budget. Again, relinearization does not consume the # noise budget at this point by any observable amount, even with the largest # possible decomposition bit count. evaluator.square(encrypted) print("Size after third squaring " + (str)(encrypted.size())) print("Noise budget after third squaring: " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") evaluator.relinearize(encrypted, ev_keys60) print("Size after relinearization: " + (str)(encrypted.size())) print("Noise budget after relinearizing (dbc = " + (str)(ev_keys60.decomposition_bit_count()) + "): " + (str)(decryptor.invariant_noise_budget(encrypted)) + " bits") decryptor.decrypt(encrypted, plain2) print("Eighth power: " + plain2.to_string())