Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The HEaaN Book

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Version 25.04.1 (Released May 2, 2025)

This book offers a comprehensive and practical guide to the HEaaN library, detailing its appropriate use cases, implementation methods, and underlying significance. For more information, please contact CryptoLab info@cryptolab.co.kr.

Introduction

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

This book provides a detailed, hands-on guide to using the HEaaN library — when to use it, how to use it, and why it matters.

It is structured into a series of chapters, each focused on a specific aspect of homomorphic encryption with HEaaN. Starting from the basics — plaintexts, ciphertexts, and key management — the book gradually builds up to more advanced topics, including bootstrapping, which is one of the most powerful and defining features of FHE schemes.

Each chapter includes:

  • a brief introduction to the topic being covered,
  • a realistic scenario where the topic becomes relevant, and
  • a set of concrete tasks with example code that solves the problem.

Finally, the book walks you through the example code line by line, explaining how each HEaaN function operates within various encrypted computation workflow—from key generation to ciphertext evaluation and decryption. Whether you’re a new developer or looking to deepen your understanding of advanced homomorphic encryption features, this guide is designed to help you learn HEaaN step by step with clarity and purpose.

In this tutorial series, we introduce an imaginary company called SuperSecure, Inc. to create a more immersive experience. This company has adopted the HEaaN library to develop an FHE solution. You will take on the role of a new intern at SuperSecure, where each chapter assigns you specific tasks, and we demonstrate how HEaaN helps accomplish them. When you're ready, let's get started from Week 0 at SuperSecure, Inc.

Week 0 at SuperSecure, Inc.

👋 Welcome to SuperSecure, Inc. 🎉!

Welcome intern! We’re excited to have you on board!

SuperSecure specializes in secure computation services powered by CryptoLab's HEaaN library, which is based on the CKKS FHE scheme.

At the interview, you already learned what we are doing : we provide a range of solutions — from running computations on encrypted client data to enabling secure queries over encrypted databases. Whatever the use case, privacy always comes first.

You’ve just joined the team as an intern, and you're about to begin your journey toward becoming a homomorphic encryption expert. Your role will be to explore real-world scenarios and implement practical tasks using HEaaN.

At first, working with a homomorphic encryption library might feel a bit unfamiliar—but don’t worry! We'll walk you through HEaaN’s core APIs, with step-by-step explanations and hands-on exercises to help you get comfortable.

Ready to dive into the world of privacy-preserving computation? Let’s get started! 🚀


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.

Hello, HEaaN World!

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

Welcome to the secure world of HEaaN! Thoughout this book, you will explore the functionalities of HEaaN library, which provides a state-of-the-art implementation for the CKKS fully homomorphic encryption (FHE) scheme 1. Although you are familiar with conventional encryption technologies, homomorphic encryption may feel different from those conventional technologies, especially when various calculations on the ciphertexts are involved. You might be unfamiliar with HEaaN, CKKS, or even FHE itself - but don't worry. We'll guide you through HEaaN step by step throughout the book!

In this chapter, we are going to make a very simple, "hello, world!" program in HEaaN: just an encryption and a decryption with a single secret key. Cryptographers call this a symmetric encryption. Let's see how we can create a ciphertext from a cleartext message and decrypt it back.

Week 1 at SuperSecure, Inc.

One of our potential clients is looking to run an AI model on their own data—but with a key requirement: security. Like many others, they’re facing limited computational resources and are actively seeking an AI inference service. The challenge? Their data is highly sensitive, tightly regulated, and it’s only natural they’re hesitant to share it in plain form.

That’s where we come in. Our company provides a secure computing environment that allows clients to offload AI computations without compromising privacy.

As a new intern, your job is to help make this possible. You won’t need to implement the AI model itself—instead, your focus will be on building the encryption and decryption flow that ensures the client’s data remains private throughout the process.

TASK: One of the simplest use case for homomorphic encryption involves a single client ("data owner") and a computation server. The client encrypts their data using their own secret key, and sends the encrypted ciphertext to the server.

The server receives the ciphertext, conducts whatever computation that needs to be done, and send back the resulting ciphertext to the client.

Then the client can decrypts it using the secret key, which is the same key for the encryption in the first place, and checks out the plain results.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <iomanip>
#include <iostream>

int main() {
    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

    HEaaN::Message message(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();

    for (HEaaN::u64 i = 0; i < message_size; ++i) {
        message[i] = {static_cast<HEaaN::Real>(i) /
                          static_cast<HEaaN::Real>(message_size),
                      0.0};
    }

    const auto sk = HEaaN::SecretKey(context);
    const auto enc = HEaaN::Encryptor(context);
    auto ctxt = HEaaN::Ciphertext(context);

    enc.encrypt(message, sk, ctxt);

    const auto eval = HEaaN::HomEvaluator(context);
    eval.add(ctxt, 1.0, ctxt);

    const auto dec = HEaaN::Decryptor(context);

    HEaaN::Message decrypted_message;
    dec.decrypt(ctxt, sk, decrypted_message);

    std::cout << "Cleartext message: \n";
    std::cout << "Original\t\tDecrypted\n";
    for (int i = 0; i < 10; i++) {
        std::cout << std::fixed << std::setprecision(4) << message[i] << "\t\t"
                  << decrypted_message[i] << '\n';
    }
}

Explanation

Now let's explore the code line-by-line.

#include "HEaaN/HEaaN.hpp"

The header file HEaaN/HEaaN.hpp is an all-in-one solution for using HEaaN. While the library also provides many individual headers that define specific objects and components, you can simply include HEaaN/HEaaN.hpp to access them all.

If you're someone who prefers minimalism, it's perfectly fine to include only the specific headers you need.

    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

These two lines set up the ParameterPreset and Context in HEaaN.

A ParameterPreset in HEaaN is a predefined set of CKKS parameters — from the basic RLWE parameters such as ciphertext dimension and modulus chain length, all the way up to highly technical tuning parameters for better performance. For now, you don’t need to worry too much about the details — just pick one of the presets we provide. Since there are many parameters to choose from, it might not be clear which one is best — but for now, we'll go with ParameterPreset::SD3, which stands for a somewhat 2 homomorphic encryption parameter ("S") supporting three multiplications.

Context in HEaaN is the general manager of parameters. As you can see, you’ll need to supply a context object to other agent classes such as Encryptor or Decryptor, as well as to data objects like Ciphertext.

    HEaaN::Message message(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();
    for (int i = 0; i < message_size; ++i) {
        message[i] = {static_cast<HEaaN::Real>(i) /
                          static_cast<HEaaN::Real>(message_size),
                      0.0};
    }

Here you can create and fill input messages. Behind the scenes, a Message object is nothing more than a vector of complex numbers.

How many complex numbers are in the vector? In CKKS, the size must be a power-of-two, and the upper bound is determined by the preset you've chosen. Since the size is a power-of-two, we can specify its logarithm (base 2) as an argument when creating a Message object.

For example, HEaaN::Message message(1) creates a message of size \(2^1 = 2\), and HEaaN::Message message(4) creates a message of size \(2^4 = 16\). In our case, the public function HEaaN::getLogFullSlots(context) helps construct a Message of maximum size, say \(M\). Here, the word slots refers to the components of the complex vector in mathematical terms.

After constructing the Message, you can fill its slots with any values that are meaningful to you. In this example, we fill the \(i\)th slot with a complex number whose real part is \(i / M\), where \(M\) is the total number of slots, and whose imaginary part is 0.

    const auto sk = HEaaN::SecretKey(context);

Here's the last thing we need to do before encrypting the message: we need to create a secret key from the given context. Behind the scenes, HEaaN will automatically generate a random secret with the help of a cryptographically secure pseudo-random number generator (CSPRNG).

    const auto enc = HEaaN::Encryptor(context);
    auto ctxt = HEaaN::Ciphertext(context);
    enc.encrypt(message, sk, ctxt);

Encryptor is an agent object in HEaaN responsible for—guess what—encryption. We construct one, and also create a data object called Ciphertext, in which the encrypted message will be safely stored. The line enc.encrypt(message, sk, ctxt); encrypts message into ctxt using the secret key sk. This is the core routine for symmetric-key encryption.

HEaaN also supports public-key encryption. If you look at the signatures of the overloaded Encryptor::encrypt functions, you'll see that you can pass a public key object in the second argument (the position currently used for sk). We'll show you more about that later.

So far, everything described must be done on the client side—that is, the data owner’s side. The encrypted ciphertext ctxt can then be sent to a computation server. Only the owner of sk can decrypt and view the contents of ctxt, so make sure to keep sk secure.

Once the server receives ctxt, it can perform any homomorphic operations required. In this example, we add a scalar value of 1.0 to each component of the complex vector that ctxt encrypts. The result is also a ciphertext, so we store it back into ctxt to save memory.

    const auto eval = HEaaN::HomEvaluator(context);
    eval.add(ctxt, 1.0, ctxt);

All the computations the server must do have been done indeed. Now, the server can send the resulting ciphertext back to the client. Suppose the client received the results.

    const auto dec = HEaaN::Decryptor(context);

    HEaaN::Message decrypted_message;
    dec.decrypt(ctxt, sk, decrypted_message);

The few lines above demonstrate how to decrypt the resulting ciphertext on the client side. Unlike encryption, we can only decrypt with the secret key sk — although in this example, we also used sk for encryption. Now, let’s print out the result using C++'s standard console output functionality.

    std::cout << "Cleartext message: \n";
    std::cout << "Original\t\tDecrypted\n";
    for (int i = 0; i < message_size; i++) {
        std::cout << std::fixed << std::setprecision(4) << message[i] << "\t\t"
                  << decrypted_message[i] << '\n';
    }

Let's run this example. Can you try it yourself? Can you modify the original message and print out similar results?


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.


  1. https://ckks.org

  2. Somewhat homomorphic encryption is a homomorphic encryption with addition and multiplication with a limited number of multiplications.

Playing with Messages

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

In the previous chapter, we walked through the full process of encryption and decryption using the CKKS scheme. You might have noticed that CKKS doesn’t just encrypt a single number; it encrypts a vector of thousands — or even more — complex numbers at once. These values are efficiently packed into a single object called the Message.

At its core, the Message class is a wrapper around a vector of complex numbers. However, there’s more to it than that. It supports dynamic multi-device allocation, and more importantly, it serves as a bridge to the homomorphic world: it can be encrypted into ciphertexts or used directly alongside cleartexts and ciphertexts in computations.

In this chapter, we’ll get hands-on with the Message class. We’ll explore how to construct and manipulate these vectors, and how they fit into real homomorphic encryption workflows.

Week 2 at SuperSecure, Inc.

🎉 Congratulations on successfully implementing the encryption and decryption flow with HEaaN!

Now it’s time to take one more step. In this section, you’ll build a simple linear function model using homomorphic operations. The model multiplies each slot of a message by a corresponding weight (i.e., component-wise vector multiplication) and then adds a single complex bias across all slots.

TASK: Building up on the code from Week 1, the server additionally computes a linear model.

The linear model is performed by one message multiplication with a ciphertext and addition of a constant complex number.

After decryption, check the precision of the results by comparing with the true output from computation in messages.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <numeric>

int main() {
    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

    HEaaN::Message message(HEaaN::getLogFullSlots(context)),
                    weights(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();

    for (int i = 0; i < message_size; ++i) {
        message[i] = std::rand() / static_cast<HEaaN::Real>(RAND_MAX);
        weights[i] = static_cast<HEaaN::Real>(i) /
                          static_cast<HEaaN::Real>(message_size);
    }
    const HEaaN::Real bias = 0.5;

    const auto sk = HEaaN::SecretKey(context);
    const auto enc = HEaaN::Encryptor(context);

    auto ctxt = HEaaN::Ciphertext(context);

    enc.encrypt(message, sk, ctxt);

    const auto eval = HEaaN::HomEvaluator(context);

    // New code : Evaluate linear model : y = x * w + b
    eval.mult(ctxt, weights, ctxt);
    eval.add(ctxt, bias, ctxt);

    const auto dec = HEaaN::Decryptor(context);
    HEaaN::Message decrypted_message;
    dec.decrypt(ctxt, sk, decrypted_message);

    // Expected message results.
    HEaaN::Message result;
    eval.mult(message, weights, result);
    eval.add(result, bias, result);

    // Output last few elements of the computed and expected results
    std::cout << "Cleartext message: \n";
    std::cout << "Expected\t\tComputed\n";
    for (HEaaN::u64 i = message_size - 10; i < message_size; i++) {
        std::cout << std::fixed << std::setprecision(4) << result[i] << "\t\t"
                  << decrypted_message[i] << '\n';
    }

    // Compare the results with the message results.
    HEaaN::Real max_error = std::transform_reduce(
        result.begin(), result.end(),
        decrypted_message.begin(), 0.0,
        [](auto x, auto y) { return std::max(x, y); },
        [](auto x, auto y) { return abs(x - y); });

    std::cout << "Log max error: " << std::log2(max_error) << "\n";
}

Explanation

Now, let's explore the code line by line.

The beginning of the code is the same as the previous chapter. We won't repeat the explanations here. For details, please refer to Chapter 1 — Hello, HEaaN World! We first focus on the following code listing, which creates the message objects.

    HEaaN::Message message(HEaaN::getLogFullSlots(context)),
                    weights(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();

How is the size of the constructed Message determined in HEaaN? When constructing a message, you specify its size using a parameter called log_slots, as you can see in the declaration of the constructor of Message:

explicit HEaaN::Message(u64 log_slots);

The number of complex slots in the message is calculated as \(2^{\text{log_slots}}\) that is, a power-of-two based on the log_slots value. For example:

  • If log_slots = 0, the message is just a single complex value.
  • If log_slots = 5, the message contains 32 complex slots.
  • If log_slots = 10, the message holds 1024 slots.

There is an upper bound to this size; half of the polynomial degree, which is a fixed value determined by homomorphic encryption (HE) parameter you specified when creating Context. You can check this upper bound with HEaaN::getLogFullSlots() function, which requires the Context object as its argument.

As you may have noticed, the message size must always be a power-of-two. But why? The key reason is that the message size must evenly divide the maximum number of slots, which is half the degree. Since the degree itself is always chosen to be a power-of-two, this requirement naturally applies to the message size as well.

We'll cover in more detail later why the "degree" must follow this power-of-two structure. For now, just remember: your message size must always be a power-of-two; it’s a built-in constraint.

    for (int i = 0; i < message_size; ++i) {
        message[i] = std::rand() / static_cast<HEaaN::Real>(RAND_MAX);
        weights[i] = static_cast<HEaaN::Real>(i) /
                          static_cast<HEaaN::Real>(message_size);
    }

    const HEaaN::Real bias = 0.5;

Once the message is constructed, you can fill it by assigning a value to each slot—much like working with a pre-allocated vector. Each message[i] is a standard complex double object, so you can initialize the slots in a familiar way. For example,

    // initialize real and imag parts separately
    message[i].real(x);
    message[i].imag(y);
    // initialize real and imag parts together
    message[i] = {x, y};

You can also assign real numbers directly, and the imaginary part will be treated as zero by default - just like in our scenario. Now, let’s take a closer look at the core computation in our task.

    const auto eval = HEaaN::HomEvaluator(context);

    // Evaluate linear model : y = x * w + b
    eval.mult(ctxt, weights, ctxt);
    eval.add(ctxt, bias, ctxt);

    // ...

    // Expected message result.
    HEaaN::Message result;
    eval.mult(message, weights, result);
    eval.add(result, bias, result);

This code block tests the computation between vectors and constants: $$ (y_0,y_1,\cdots,y_{n-1}) = (x_0,x_1,\cdots,x_{n-1}) \odot (w_0,w_1,\cdots,w_{n-1}) + (b,b,\cdots,b) $$ Here, we're performing elementwise multiplication between two vectors, followed by a scalar bias addition.

As shown in the equation, values that remain constant across all slots — such as the bias term \(b\) — can be efficiently represented using the HEaaN::Real type, which internally broadcasts a single scalar value across the message space. In contrast, values like weights that differ from slot to slot must be explicitly represented using a Message, which holds a vector of complex numbers corresponding to each slot.

With the help of the HomEvaluator, we can perform operations not only between two messages, but also between a ciphertext and a message. Let’s try running the same operations in both cases. We expect that, once decrypted, the output ciphertext will match the result computed in the cleartext domain.

Note that the message result is constructed without any arguments. This works because the Message class provides a default constructor, which creates an empty message with no initialized slots. In our case, we’re using it to allocate a placeholder for storing the result of an operation, just like how you'd create an empty vector before filling it.

    // Compare the results with the message results.
    HEaaN::Real max_error = std::transform_reduce(
        result.begin(), result.end(),
        decrypted_message.begin(), 0.0,
        [](auto x, auto y) { return std::max(x, y); },
        [](auto x, auto y) { return abs(x - y); });

    std::cout << "Log max error: " << std::log2(max_error) << "\n";

Finally, we compare the decrypted message with the expected result computed on cleartext values. To quantify the difference, we typically use the maximum error as a metric. This involves computing the absolute difference at each slot and then taking the maximum value across all slots.

A convenient way to perform this computation is by using the standard library, and in this example, we use std::transform_reduce() to calculate the max error efficiently.

This is possible because the message object provides iterators such as begin() and end() member functions. Thanks to this, we can treat the message just like a standard vector (std::vector) in C++, allowing us to apply many C++ standard algorithms directly.

This is a fully working example code. Try running it as-is and experiment with modifications — it’s a great way to deepen your understanding of the Message class.


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.

Twirling Keys

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

Even if the ciphertext—produced by encrypting cleartext with the client’s secret key—is exposed to third parties during transmission to the computation server, there is no risk of compromising sensitive information, as the original data cannot be recovered without the secret key.

However, the ciphertext alone cannot support operations such as multiplication, rotation, or conjugation on the encrypted data. To enable these types of homomorphic operations, specific auxiliary keys corresponding to each operation are required. These are referred to as evaluation keys. Evaluation keys can be safely shared and used publicly, as they do not reveal any information about the underlying secret key.

That said, key management remains a critical component of any homomorphic encryption system. Even when built upon robust cryptographic principles, improper handling or design of key management mechanisms can introduce significant security vulnerabilities.

To mitigate such risks, it is essential to explicitly define the data owner and to strictly ensure that the secret (private) key is never disclosed to any untrusted entity under any circumstances.

In this chapter, we provide a detailed walkthrough on how to use the KeyGenerator and KeyPack classes to generate the necessary public keys—such as encryption and evaluation keys—and how to serialize them for secure transfer.

Week 3 at SuperSecure, Inc.

In the earlier scenario, the computation server was unable to fully leverage the capabilities of the homomorphic encryption (HE) system—at least not until the necessary evaluation keys had been provided.

Now it’s your turn to extend this setup. Your objective is to design a key management workflow in which the data owner (the client) generates all required public keys and securely transfers them to the computation server.

To simplify the implementation, both the client-side and server-side logic will be contained within a single example.

TASK: In this scenario, the client is responsible for generating the appropriate public keys and exporting them—either to files or directly to I/O streams. These exported keys are then transferred to the computation server. On the server side, the keys must be imported from the received sources before they can be used to perform homomorphic operations.

In particular, rotation keys should be generated for all power-of-two indices. This ensures that the server can efficiently perform arbitrary rotation operations during computation.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <iostream>
#include <filesystem>

int main() {
    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

    const auto sk = HEaaN::SecretKey(context);
    const auto pack = HEaaN::KeyPack(context);
    const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

    std::cout << "Generating evaluation keys ... \n";
    keygen.genEncKey();
    keygen.genMultKey();
    keygen.genRotKeyBundle();
    keygen.genConjKey();
    std::cout << "Key generation complete.\n";

    std::string key_dir_path = "./keys";
    if (!std::filesystem::exists(key_dir_path)) {
        std::filesystem::create_directory(key_dir_path);
    }
    sk.save(key_dir_path + "/SecretKey.bin");
    pack.save(key_dir_path);

     /// ---- On the computation server side ----

    auto pack_server = HEaaN::KeyPack(context, key_dir_path);
}

Explanation

Let’s walk through the source code step by step.

KeyPack and KeyGenerator Classes

const auto sk = HEaaN::SecretKey(context);

This line creates a secret key, just as in earlier examples. What’s different here is the purpose: we’re generating evaluation keys from the secret key. In HEaaN, two core classes are involved in this process:

  • KeyPack: a container for storing evaluation keys, with built-in support for saving and loading keys.

  • KeyGenerator: responsible for generating the evaluation keys from the secret key.

Let’s first examine KeyPack.

const auto pack = HEaaN::KeyPack(context);

Here, a KeyPack object is instantiated using a Context object. In homomorphic encryption, all keys and ciphertexts must be bound to a specific cryptographic context, which encapsulates critical scheme parameters. At this point, the pack object is empty and must be populated with evaluation keys.

There is also an alternative constructor:

explicit HEaaN::KeyPack(const Context &context, const std::string &key_dir_path);

This version creates a KeyPack by loading keys from the specified path. We’ll explore this server-side use case in the next chapter.

Additional constructors exist for advanced use cases like sparse secret encapsulation, but those are beyond our current scope and will be covered separately.

Next, let’s turn to KeyGenerator.

const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

This creates a KeyGenerator object, which requires:

  • a Context (FHE parameter configuration),

  • a SecretKey (used to derive evaluation keys),

  • and a KeyPack (to store the generated keys).

Note that passing an existing KeyPack is optional. If not provided, KeyGenerator will internally create one, which can be accessed later using keygen.getKeyPack(). However, for clarity and explicit control, we choose to supply pack directly.

Generating Evaluation Keys

keygen.genEncKey();
keygen.genMultKey();
keygen.genRotKeyBundle();
keygen.genConjKey();

These lines generate the required public and evaluation keys:

  • genEncKey(): creates a public encryption key for encrypting data without the secret key.

  • genMultKey(): generates the multiplication key (also called a relinearization key) used when multiplying ciphertexts by another ciphertexts.

  • genRotKeyBundle(): automatically generates rotation keys for all power-of-two indices, enabling efficient vector rotations.

  • genConjKey(): generates the conjugation key, which supports complex conjugation operations( $\overline{a+bi}= a-bi$ ) in CKKS.

About Rotation Keys

Rotation in the CKKS scheme refers to cyclically shifting the slots of a cleartext vector in the encrypted state. For example, a left rotation by $k$ would map: $$ (x_0, x_1, \cdots, x_{n-1}) \mapsto (x_k, x_{k+1}, \cdots, x_{n-1}, x_{0} \cdots, x_{k-1}) $$ This operation is essential for tasks like matrix–vector multiplication, convolutions, and other linear algebraic computations.

You can generate rotation keys for specific indices using:

void KeyGenerator::genLeftRotKey(u64 rot) const;
void KeyGenerator::genRightRotKey(u64 rot) const;

However, calling genRotKeyBundle() simplifies the process by generating both left and right rotation keys for all power-of-two indices. For example, using the SD3 preset—which provides up to \(2^{12}=4096\) slots—genRotKeyBundle() will create rotation keys for indices \(1, 2, 4, 8, \cdots, 2048\)1.

Save and Load

sk.save(key_dir_path + "/SecretKey.bin");
pack.save(key_dir_path);
  • sk.save(...): saves the secret key to a file or stream (must remain private).

  • pack.save(...): saves the key pack (including encryption and evaluation keys) to the specified directory or stream.

HEaaN provides flexible serialization support for saving keys and other data containers. Secret keys, key packs, and related objects such as ciphertext and plaintext can be saved either to files or directly to output streams (e.g., std::ostream). Corresponding load functions are available for deserialization. Notably, when constructing a KeyPack with a path argument, the stored keys are automatically loaded from the specified directory. This design simplifies key management and enables seamless integration into various I/O workflows. However, if the specified directory does not contain all the required key files in the expected format, a runtime error occurs. It is therefore important to ensure that the key files are properly saved and verified before attempting to load them via path-based initialization.

Remark : Encryption Key

Consider a scenario where the server is the data owner and wants to encrypt incoming client queries using its own secret key to protect data during transmission. Naturally, the server should never expose its secret key.

Instead, the server can provide its public encryption key to clients. This allows clients to perform encryption securely, while the server retains sole authority to decrypt using its private key.


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.


  1. Note that the left and right rotation with index 4096 gives the original ciphertext.

Homomorphic Operations, Part 1

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

In this chapter, you’ll explore the basic homomorphic operations that serve as the foundation for building more complex computation models.

A homomorphic operation is a mathematical operation that behaves identically on both cleartext and encrypted data. This powerful property enables computations to be performed directly on ciphertexts—producing encrypted results that, once decrypted, match the results of operations performed on the original data.

You’ll be working with the HomEvaluator class to perform these operations. As you go through the examples, you’ll implement a simple linear model on encrypted data using homomorphic operations. Along the way, you'll also learn how evaluation keys play a crucial role in enabling these encrypted computations.

Week 4 at SuperSecure, Inc.

Up to now, we have encrypted client queries and evaluated the linear model, which has cleartext weights in Week 2. But the situation has changed. As our service continues to expand, we have grown more concerned about the security of model weights, which should also be treated as private and properly protected.

For this reason, the linear model should be inference on encrypted data. Since both the weights and queries are encrypted using the same secret key, we will provide the encryption key to clients instead of having them encrypt the data with their own secret key.

As the server takes on the role of the data owner, the clients are no longer responsible for key management. Furthermore, we separate the key management system from the computation server, which will consist of a cluster of compute nodes. Please separate the roles of each party but implement each code into a single example code.

TASK: The key manager generates both the encryption key and the evaluation keys. The encryption key is distributed to the client, while the evaluation keys are bundled into a KeyPack and transferred to the server.

The client encrypts the input data using the encryption key and sends the ciphertexts to the server.

The server loads the ciphertexts and the KeyPack, then performs homomorphic operations: one ciphertext-ciphertext multiplication and one constant addition.

Finally, the result is decrypted using the secret key, either by the server or the key manager.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <complex>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <sstream>

int main() {
    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

    const auto sk = HEaaN::SecretKey(context);
    const auto pack = HEaaN::KeyPack(context);
    const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

    std::cout << "Generate encryption / multiplication key ... \n";
    keygen.genEncKey();
    keygen.genMultKey();
    std::cout << "done \n";

    std::stringstream stream_pack, stream_enc;
    pack.save(stream_pack);

    auto enc_key = pack.getEncKey();
    save(*enc_key, stream_enc);

    ////////////////////
    /// Client Side ////
    ////////////////////

    HEaaN::Message message(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();

    for (int i = 0; i < message_size; ++i) {
        message[i] = std::rand() / static_cast<HEaaN::Real>(RAND_MAX);
    }

    const auto enc = HEaaN::Encryptor(context);
    auto pack_client = HEaaN::KeyPack(context);
    pack_client.loadEncKey(stream_enc);

    auto ctxt = HEaaN::Ciphertext(context);

    enc.encrypt(message, pack_client, ctxt);

    std::stringstream stream;
    ctxt.save(stream);

    ////////////////////
    /// Server Side ////
    ////////////////////

    auto ctxt_query = HEaaN::Ciphertext(context);
    ctxt_query.load(stream);

    auto pack_server = HEaaN::KeyPack(context);
    pack_server.load(stream_pack);

    auto weights = HEaaN::Message(HEaaN::getLogFullSlots(context));
    for (int i = 0; i < weights.getSize(); ++i) {
        weights[i] = {static_cast<HEaaN::Real>(i) /
                          static_cast<HEaaN::Real>(message_size),
                      0.0};
    }
    const HEaaN::Real bias = 0.5;

    auto ctxt_weights = HEaaN::Ciphertext(context);
    enc.encrypt(weights, pack_server, ctxt_weights);

    const auto eval = HEaaN::HomEvaluator(context, pack_server);

    // TASK : Evaluate linear model on encrypted data: y = x * w + b
    auto ctxt_res = HEaaN::Ciphertext(context);
    eval.mult(ctxt_query, ctxt_weights, ctxt_res);
    eval.add(ctxt_res, bias, ctxt_res);

    const auto dec = HEaaN::Decryptor(context);

    HEaaN::Message decrypted_message;
    dec.decrypt(ctxt_res, sk, decrypted_message);

    // Expected message result.
    HEaaN::Message result;
    eval.mult(message, weights, result);
    eval.add(result, bias, result);

    // Output last few elements of the computed and expected results
    std::cout << "Cleartext message: \n";
    std::cout << "Expected\t\tComputed\n";
    for (HEaaN::u64 i = message_size - 10; i < message_size; i++) {
        std::cout << std::fixed << std::setprecision(4) << result[i] << "\t\t"
                  << decrypted_message[i] << '\n';
    }

    HEaaN::Real max_error = std::transform_reduce(
        result.begin(), result.end(),
        decrypted_message.begin(), 0.0,
        [](auto x, auto y) { return std::max(x, y); },
        [](auto x, auto y) { return abs(x - y); });

    std::cout << "Log max error: " << std::log2(max_error) << "\n";
}

Explanation

Let's explore the code line by line!

We’ve structured the code into three logical parts: key manager side, client side, and server side. Don’t feel intimidated by the new role — the key manager is straightforward once you understand its responsibilities. The Key Manager is responsible for securely holding the secret key used for encryption. It could be implemented using secure hardware like an HSM (Hardware Security Module) or a TEE (Trusted Execution Environment).

    const auto sk = HEaaN::SecretKey(context);
    const auto pack = HEaaN::KeyPack(context);
    const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

We start by creating a KeyPack object and a KeyGenerator object. For the details of these classes, see the previous chapter.

    std::cout << "Generate encryption / multiplication key ... \n";
    keygen.genEncKey();
    keygen.genMultKey();
    std::cout << "done \n";

    std::stringstream stream_pack, stream_enc;
    pack.save(stream_pack);

    auto enc_key = pack.getEncKey();
    save(*enc_key, stream_enc);

The KeyGenerator offers several genXXKey() APIs to generate specific keys, such as encryption key and multiplication key, all of which are, by default, stored in the internal KeyPack.

In our scenario:

  • The client needs an encryption key to encrypt query data.
  • The server needs a multiplication key to evaluate queries.

You can save keys individually or all at once by storing the entire KeyPack. This is especially helpful on the server side, where a large number of keys are often required — managing them individually would be too much work.

    // Client Side
    // ...

    const auto enc = HEaaN::Encryptor(context);
    auto pack_client = HEaaN::KeyPack(context);
    pack_client.loadEncKey(stream_enc);

    auto ctxt = HEaaN::Ciphertext(context);

    enc.encrypt(message, pack_client, ctxt);

    std::stringstream stream;
    ctxt.save(stream);

If you’ve followed along this far, you’re probably familiar with the basic encryption process. Here's how the client encrypts data using a received key: The client creates an encryption object and an empty KeyPack. It loads the encryption key using the loadEncKey() API from the KeyPack. It then performs public key encryption by passing the KeyPack as an argument. Once the data is encrypted, the ciphertext is sent to the server.

    // Server side
    auto ctxt_query = HEaaN::Ciphertext(context);
    ctxt_query.load(stream);

    auto pack_server = HEaaN::KeyPack(context);
    pack_server.load(stream_pack);

On the server side, the query (now in ciphertext form) and the required keys are retrieved from the Key Manager. The server loads the ciphertext using a Ciphertext object from a stream and retrieves the necessary keys from the preloaded KeyPack.

    auto ctxt_weights = HEaaN::Ciphertext(context);
    enc.encrypt(weights, pack_server, ctxt_weights);

Since the weights are the most sensitive part of the model, we also encrypt them using public key encryption to ensure their confidentiality.

    const auto eval = HEaaN::HomEvaluator(context, pack_server);

Now it’s time to perform encrypted computation using evaluation keys.

When constructing the Homevaluator, we pass in the preloaded KeyPack directly. Alternatively, you can provide a path to the key directory, in which case the evaluator will build a KeyPack from that path. During computation, the evaluator internally fetches the necessary evaluation keys from the KeyPack and performs the appropriate operations.

    // TASK : Evaluate linear model on encrypted data: y = x * w + b
    auto ctxt_res = HEaaN::Ciphertext(context);
    eval.mult(ctxt_query, ctxt_weights, ctxt_res);
    eval.add(ctxt_res, bias, ctxt_res);

Once the multiplication key is loaded, the Homevaluator is now ready to perform ciphertext multiplication.

As you can see in the code, performing operations between ciphertexts is no different from operations involving cleartext values. Thanks to C++'s function overloading, you can use the same API names for both types — even though the underlying types may differ. This makes homomorphic computations feel intuitive and seamless in code.

    const auto dec = HEaaN::Decryptor(context);

    HEaaN::Message decrypted_message;
    dec.decrypt(ctxt_res, sk, decrypted_message);

    // precision test ...

After the inference is complete, the decryption process can be performed either by the server or the Key Manager, depending on the security architecture.

In our example, we assume the server performs the decryption and retrieves the final result. Just like in Chapter 3, this example also includes a precision test to verify that the linear model is working correctly.

Finally, you can run the code yourself to verify whether the decrypted output matches the original cleartext 🚀

Remark

The word ‘homomorphic’ stems from the algebra. It means that a function connecting two algebraic spaces preserves algebraic structures like multiplication. In our case, we have two algebraic spaces; one is a complex vector space that messages live in and the other one is polynomial rings that ciphertexts live in. Both spaces have their own multiplication (the former one is an element-wise multiplication) and the encrypt function preserves the multiplication. In conclusion, it allows multiplication on encrypted data as if it were cleartext data.


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.

Homomorphic Operations, Part 2

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

In this chapter, we’ll continue where we left off with homomorphic operations, diving deeper into how they enable more flexible encrypted computations.

This time, we’ll focus on two powerful operations: rotation and conjugation. These are essential tools when working with vectorized encrypted data, and understanding how to use them will open the door to more advanced use cases.

Week 5 at SuperSecure, Inc.

Thanks to the successful launch of our linear model inference service, we’re now adding a new layer on top of it—an average layer that computes the mean of the result vector. This additional step helps minimize potential information leakage about the model when returning results to clients.

To maximize throughput, we also recommend that clients split real-valued input data into two parts: the real and imaginary components of a complex message. Since our model uses only real-valued weights and biases, this kind of complex packing preserves the correctness of the computation.

Keep in mind that when applying the average layer, you must compute the sum across both the real and imaginary parts, as both carry meaningful input data.

In this example, separating the key management system is not required.

TASK: The server computes the sum of all slots in the result vector by applying a sequence of rotations and additions to accumulate values into a single slot. At this point, the real and imaginary parts store partial sums of different segments.

To combine them, the server applies multImagUnit followed by addition, effectively computing (real + imag) in the real part.

Finally, conjugation and addition are applied to eliminate any remaining imaginary part, yielding a real-valued result. This helps minimize potential information leakage from the complex representation.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <complex>
#include <cstdlib>
#include <iomanip>
#include <iostream>

int main() {
    const auto preset{HEaaN::ParameterPreset::SD3};
    const auto context = HEaaN::makeContext(preset);

    const auto sk = HEaaN::SecretKey(context);
    const auto pack = HEaaN::KeyPack(context);
    const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

    std::cout << "Generate Rotation / Conjugation Keys ... \n";
    keygen.genRotKeyBundle();
    keygen.genConjKey();
    std::cout << "done\n";

    const auto log_slots = HEaaN::getLogFullSlots(context);
    HEaaN::Message message(log_slots);
    const auto message_size = message.getSize();

    for (int i = 0; i < message_size; ++i) {
        message[i].real(std::rand() / static_cast<HEaaN::Real>(RAND_MAX));
        message[i].imag(std::rand() / static_cast<HEaaN::Real>(RAND_MAX));
    }

    const auto enc = HEaaN::Encryptor(context);
    auto ctxt = HEaaN::Ciphertext(context);
    enc.encrypt(message, sk, ctxt);

    const auto eval = HEaaN::HomEvaluator(context, pack);

    // TASK : Compute the average of the input.
    auto ctxt_res = ctxt;
    auto ctxt_rot = HEaaN::Ciphertext(context);
    for (HEaaN::u64 i = 0; i < log_slots; ++i) {
        eval.leftRotate(ctxt_res, (HEaaN::u64(1) << i), ctxt_rot);
        eval.add(ctxt_res, ctxt_rot, ctxt_res);
    }
    {
        auto ctxt_i = HEaaN::Ciphertext(context);
        eval.multImagUnit(ctxt_res, ctxt_i);
        eval.add(ctxt_res, ctxt_i, ctxt_res);

        auto ctxt_conj = HEaaN::Ciphertext(context);
        eval.conjugate(ctxt_res, ctxt_conj);
        eval.add(ctxt_res, ctxt_conj, ctxt_res);
    }

    const auto dec = HEaaN::Decryptor(context);
    HEaaN::Message decrypted_average;
    dec.decrypt(ctxt_res, sk, decrypted_average);

    std::cout << "Original Message : \n";
    for (HEaaN::u64 i = 0; i < 10; i++) {
        std::cout << std::fixed << std::setprecision(4) << message[i] << '\n';
    }

    std::cout << "The average of the results is: " <<
        decrypted_average[0] / (2 * static_cast<HEaaN::Real>(message_size)) << '\n';

}

Explanation

Let's explore the code line by line.

Getting started with the key generation parts.

    std::cout << "Generate Rotation / Conjugation Keys ... \n";
    keygen.genRotKeyBundle();
    keygen.genConjKey();
    std::cout << "done\n";

In our scenario, key generator needs to generate rotation and conjugation keys. A set of rotation keys is generated via genRotKeyBundle() which generates all rotation keys corresponding to the power-of-two indices, as described in the Chapter 4.

    // TASK : Compute the average of the input.
    auto ctxt_res = ctxt;
    auto ctxt_rot = HEaaN::Ciphertext(context);
    for (HEaaN::u64 i = 0; i < log_slots; ++i) {
        eval.leftRotate(ctxt_res, (HEaaN::u64(1) << i), ctxt_rot);
        eval.add(ctxt_res, ctxt_rot, ctxt_res);
    }

We begin by preparing the result ciphertext and a temporary ciphertext used during computation. The result ciphertext is initialized as a deep copy of the input ciphertext, ensuring that modifications don't affect the original.

To compute the average of all encrypted slots, the first step is to sum over all components. But how can we perform computations across different slots (also called components)?

The typical approach is to rotate the ciphertext so that values from other slots are moved into the first slot, allowing for accumulation. This requires a special operation called left rotation, which shifts the positions of the slot values to the left within the ciphertext.

Here's how rotation works : $$ (x_0,x_1,\cdots, x_i, \cdots, x_{n-1}) \xrightarrow{\text{left rot by }i} (x_i, x_{i+1},\cdots, x_{n-1}, x_0, \cdots, x_{i-1}) $$

Now, let’s explore how the average is computed using rotations and additions on small-sized messages. Consider the case where \(\text{log_slots} = 2\), meaning we have 4 slots. The computation involves two rotation steps — by 1 and by 2 positions.

Imagine how each slot in the result ciphertext (ctxt_res) evolves during this process. The following diagram illustrates how the values shift and accumulate through each rotation and addition:

$$ \begin{aligned} (x_0,x_1,x_2,x_3) &\xrightarrow{\text{left rot by 1}} (x_1, x_2, x_3, x_0) \\ (x_0 + x_1,x_1 + x_2,x_2 + x_3,x_3 + x_0) &\xrightarrow{\text{left rot by 2}} (x_2 + x_3, x_3 + x_0, x_0 + x_1, x_1 + x_2) \\ \end{aligned} $$ These steps show how ctxt_res is updated by adding rotated versions of itself (ctxt_rot) at each stage. At every iteration, we rotate the current ciphertext and add it back to the result.

If you focus on the first slot (index 0), you can see the core idea of the algorithm:

  • In the first rotation, \(x_1\) is brought to the first slot and added to \(x_0\).
  • In the next step, \(x_2 + x_3\)​ is rotated into the first slot and added to the previous sum, \(x_0 + x_1\).

This pattern generalizes to any message size \(n\). In the final step, a rotation by \(n/2\) brings the sum of the back half of the message (i.e., \(x_{n/2} +\cdots + x_{n-1}\) ) to the front, and it is combined with the sum of the front half \((x_0 + x_1+\cdots + x_{n/2-1})\).

Now, you can see how the sum is computed using a series of rotations and additions. While the average is typically obtained by dividing the sum by \(n\), we chose to perform this division after decryption in the cleartext domain. Since the message size \(n\) isn't considered sensitive information, exposing it doesn't pose a significant security risk.

    {
        auto ctxt_i = HEaaN::Ciphertext(context);
        eval.multImagUnit(ctxt_res, ctxt_i);
        eval.add(ctxt_res, ctxt_i, ctxt_res);

        auto ctxt_conj = HEaaN::Ciphertext(context);
        eval.conjugate(ctxt_res, ctxt_conj);
        eval.add(ctxt_res, ctxt_conj, ctxt_res);
    }

Next, we want to combine the real and imaginary parts of each slot into a single real value. To do this, we introduce two new operations: multImagUnit() and conjugate().

  • multImagUnit() multiplies the encrypted complex message by the imaginary unit \(i\). Note that this operation doesn't require any evaluation key.
  • conjugate() applies complex conjugation to the encrypted message.

When we apply multImagUnit() followed by an addition, we obtain the following transformation: $$ x + iy \mapsto (x + y) + i (x - y)$$ This result still contains an imaginary component. Since clients could potentially recover the original complex value \(x+iy\) from it, we further eliminate the imaginary part using conjugation and addition: $$ (x + y) + i (x - y) \xrightarrow{conj + add} 2(x + y) \\ $$ This allows us to extract only the real part \(2(x+y)\), effectively summing the real and imaginary parts. At this point, we've completed the summation across both slots and imaginary parts.

Give it a try yourself and see if you can follow how the rotations and conjugations work together to enable this transformation!

Remark - Rotation without exact keys

In this scenario, we only performed rotations with power-of-two indices, so using genRotKeyBundle() was sufficient. This method conveniently generates just the necessary keys for those specific rotations.

However, in more general cases, supporting all possible rotation indices would require generating a large number of keys — which quickly becomes inefficient in terms of both memory and performance.

To address this, HEaaN employs a clever strategy: Even if you only generate the rotation key for index \(1\), the library can internally compose multiple rotations to achieve any desired index. The downside is that this approach can be significantly slower, especially when many rotations are needed.

A more efficient approach is to generate a select set of rotation keys that can be combined to perform any required rotation with fewer operations. HEaaN supports this optimization and automatically minimizes the number of rotation steps based on the available keys.

So if you're looking to minimize key size, you can compute a minimal key set tailored to your rotation pattern. But if memory size isn't a major concern, sticking with genRotKeyBundle() is still a simple and practical choice.


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.

Bootstrapping, Part 1

Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

Table of Contents:

What really sets the FHE scheme apart from earlier pre-FHE approaches is its ability to support computations with unbounded circuit depth — a game-changer for practical encrypted computing. The reason circuit depth is usually bounded in traditional homomorphic encryption schemes is because they use a concept called levels. Each multiplication consumes one level, and once the ciphertext reaches level 0, no further multiplications can be performed.

Our CKKS scheme supports bootstrapping — a technique that brings a ciphertext’s level back up to its original state, effectively extending the circuit’s lifespan. However, bootstrapping only works if the scheme is configured with parameters that explicitly support it.

Bootstrapping might sound complex, but in this chapter, we’ll show you how the Bootstrapper class makes it surprisingly easy to use.

Week 6 at SuperSecure, Inc.

After successfully deploying a linear model, we’re now aiming to build more complex models. However, as we increase the depth of the model or try to approximate activation functions using polynomials, we frequently run into level exhaustion issues.

Since decrypting and re-encrypting is out of the question, we’ve decided to integrate bootstrapping technique into our pipeline.

As a first step, you’ve been tasked with writing a bootstrap-enabled prototype that runs without separating the key manager system — just for internal testing purposes.

Task : Begin by setting up the FHE parameters and initializing the evaluation modules required for homomorphic operations.

Next, use the key generator to produce the necessary keys. Note that for this scenario, full power-of-two rotation keys are not required — generate only the specific rotation keys needed for the bootstrapping process.

Once the setup is complete, run a simple computation that consumes a few levels, and then apply the bootstrapping operation.

Finally, verify and print how the ciphertext level is restored after bootstrapping, comparing it to the level before and after the operation.

Code

open code
#include "HEaaN/HEaaN.hpp"

#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <numeric>

int main() {
    const auto preset{HEaaN::ParameterPreset::FGb};
    const auto context = makeContext(preset);
    const auto log_slots = HEaaN::getLogFullSlots(context);

    const auto sk = HEaaN::SecretKey(context);
    const auto pack = HEaaN::KeyPack(context);
    const auto keygen = HEaaN::KeyGenerator(context, sk, pack);

    const bool min_key = false;

    std::cout << "Generate enc / conj / mult keys ... \n";
    keygen.genEncKey();
    keygen.genConjKey();
    keygen.genMultKey();
    std::cout << "Generate rotation keys used in the bootstrap process ...\n";
    keygen.genRotKeysForBootstrap(log_slots, min_key);

    const auto eval = HEaaN::HomEvaluator(context, pack);

    std::cout << "Generate Bootstrapper ...\n";
    const auto btp = HEaaN::Bootstrapper(eval, log_slots, min_key);

    HEaaN::Message message(HEaaN::getLogFullSlots(context));
    const auto message_size = message.getSize();

    for (int i = 0; i < message_size; ++i) {
        message[i].real(std::rand() / static_cast<HEaaN::Real>(RAND_MAX));
        message[i].imag(std::rand() / static_cast<HEaaN::Real>(RAND_MAX));
    }

    HEaaN::Ciphertext ctxt(context), ctxt_res(context), ctxt_boot(context);
    const auto enc = HEaaN::Encryptor(context);
    enc.encrypt(message, sk, ctxt, 4);
    std::cout << "Input Ciphertext - level : " << ctxt.getLevel()
              << "\n";

    eval.mult(ctxt, ctxt, ctxt_res);

    std::cout << "Result Ciphertext - level : " << ctxt_res.getLevel() << "\n";

    std::cout << "Bootstrap ... \n";
    if (ctxt_res.getLevel() < btp.getMinLevelForBootstrap())
        throw HEaaN::RuntimeException(
            "The input Ciphertext is smaller than the minimum level for bootstrap");
    btp.bootstrap(ctxt_res, ctxt_boot, true);

    std::cout << "Bootstrapped Ciphertext - level : "
                << ctxt_boot.getLevel() << "\n";

    const auto dec = HEaaN::Decryptor(context);
    HEaaN::Message dmsg_res, dmsg_boot;
    dec.decrypt(ctxt_res, sk, dmsg_res);
    dec.decrypt(ctxt_boot, sk, dmsg_boot);

    std::cout << "\nPlaintext Message : \n";
    std::cout << std::left << std::setw(24) << "Computed" << "Bootstrap \n";
    std::cout << std::fixed << std::setprecision(4);
    for (HEaaN::u64 i = message_size - 10; i < message_size; i++) {
        std::cout << std::left << std::setw(24)
                  << dmsg_res[i] << dmsg_boot[i] << "\n";
    }

    HEaaN::Real max_error = std::transform_reduce(
        dmsg_res.begin(), dmsg_res.end(), dmsg_boot.begin(),
        0.0, [](auto x, auto y) { return std::max(x, y); },
        [](auto x, auto y) { return std::abs(x - y);});
    std::cout << "Log max error: " << std::log2(max_error) << "\n";
}

Explanation

Let's explore the code line by line.

In this post, we’ll walk through an example code for performing bootstrapping. The entire process is divided into three parts, and each step can be handled with a simple API call.

  • Generate keys for bootstrapping
  • Construct the bootstrapper
  • Run bootstrapping after a single multiplication

Let’s dive into each step. We’ll start by introducing a new parameter, \(FGb\).

    const auto preset{HEaaN::ParameterPreset::FGb};

We introduce a new parameter, \(FGb\), which enables bootstrapping. The first letter "F" stands for Fully Homomorphic Encryption (FHE)—a scheme that supports unlimited multiplication through bootstrapping. This is in contrast to the previous parameter, \(SD3\), which represents a somewhat homomorphic encryption scheme and supports only a limited number of multiplications without bootstrapping.

The second letter "G"1 indicates a log-degree of 16, referring to the degree of the polynomial used in the underlying algebraic structure. For now, it’s enough to know that this allows for a maximum message size of 32,768 slots, which is half the polynomial degree.

We’ll explore the parameter details more thoroughly in the upcoming Parameter section.

    const bool min_key = false;

This variable determines whether key size optimization will be applied. It’s a boolean flag that can be passed to both the key generation function and the constructor of the bootstrapper. However, providing it explicitly is optional, as both functions default to min_key = false.

When set to true, the key size is significantly reduced—with minimal performance trade-offs. For example, in the \(FGb\) parameter setting, the number of rotation keys drops from 38 to just 8, while bootstrapping time increases by only about 10–20%.

Whether to enable this option depends on your specific use case. If minimizing key size is a priority and slight performance degradation is acceptable, enabling it can be a worthwhile optimization.

    std::cout << "Generate enc / conj / mult keys ... \n";
    keygen.genEncKey();
    keygen.genConjKey();
    keygen.genMultKey();
    std::cout << "Generate rotation keys used in the bootstrap process ...\n";
    keygen.genRotKeysForBootstrap(log_slots, min_key);

Bootstrapping requires conjugation keys, multiplication keys, and rotation keys. Among these, rotation keys can be generated using the genRotKeysForBootstrap() function. Unlike the general genRotKeyBundle() method, this approach does not require generating rotation keys for all power-of-two indices.

Let’s take a closer look at the arguments of the key generation function. The log_slots argument specifies the base-2 logarithm of the number of slots in the input ciphertext. Note that the number of slots in the ciphertext refers to the number of message slots encrypted within that ciphertext.

If you enable the min key optimization, it’s sufficient to generate only the minimal set of rotation keys by setting min_key = true as the second argument.

    std::cout << "Generate Bootstrapper ...\n";
    const auto btp = HEaaN::Bootstrapper(eval, log_slots, min_key);

Note that the HomEvaluator is passed to the bootstrapper so that it can access the evaluation key managed by the evaluator.

If you're bootstrapping a ciphertext that uses the full number of slots, you don't need to explicitly specify the log_slots argument. However, if the ciphertext uses fewer slots, you must provide the same log_slots value used during key generation to ensure consistency.

The min_key option in the bootstrapper’s constructor is set to false by default. If you generated only the minimal set of rotation keys, make sure to set min_key = true in the constructor as well.

When the bootstrapper is constructed, it internally generates the constants required for bootstrapping, assuming full-slot ciphertexts by default. If the log_slots argument is explicitly provided, the bootstrapper creates the corresponding bootstrapping constants for that slot size instead. Regardless of this, bootstrapping for single-slot ciphertexts is always supported.

    std::cout << "Input Ciphertext - level : " << ctxt.getLevel()
              << "\n";

    eval.mult(ctxt, ctxt, ctxt_res);

    std::cout << "Result Ciphertext - level : " << ctxt_res.getLevel() << "\n";

You can verify that a multiplication operation reduces the ciphertext's level by one. The only way to restore the level while preserving the encrypted message with negligible error is to perform bootstrapping.

You can confirm this by running the bootstrap and printing the ciphertext’s level as shown below:

    std::cout << "Bootstrap ... \n";
    if (ctxt_res.getLevel() < btp.getMinLevelForBootstrap())
        throw HEaaN::RuntimeException(
            "The input Ciphertext is smaller than the minimum level for bootstrap");
    btp.bootstrap(ctxt_res, ctxt_boot, true);

    std::cout << "Bootstrapped Ciphertext - level : "
                << ctxt_boot.getLevel() << "\n";

Before bootstrapping, make sure that the input ciphertext has a level greater than or equal to the minimum level required for bootstrapping. In most cases—including when using the \(FGb\) parameter—the minimum level is 3, which is consumed during the SlotToCoeff step. (See Remark for more details.)

Additionally, the third argument in the bootstrap function should be set to true if the encrypted message contains complex numbers. If the message consists of real numbers only, you can omit this argument—doing so results in a bootstrapping process that is approximately 30% faster.

Remark – How Does Bootstrapping Work?

You don’t need a deep mathematical understanding of bootstrapping to use it effectively. However, having a high-level overview of how it works can provide valuable intuition. For example, it may help you understand why various evaluation keys are required and how they're used, why bootstrapping is more computationally intensive than other homomorphic operations, and why there are range limitations on input messages, among other things.

Bootstrapping generally consists of four main steps: $$ \text{SlotToCoeff} \to \text{LevelRecover} \to \text{CoeffToSlot} \to \text{EvalMod} $$

The \(\text{SlotToCoeff}\) and \(\text{CoeffToSlot}\) steps perform homomorphic evaluations of the DFT and inverse DFT (iDFT) matrices, respectively. \(\text{EvalMod}\) evaluates a polynomial which approximates the function that computes modulo \(1\) within a given range.

These steps rely on different evaluation keys:

  • Rotation keys are required for matrix evaluations (i.e., \(\text{SlotToCoeff}\) and \(\text{CoeffToSlot}\))
  • Multiplication key is needed for polynomial evaluations in \(\text{EvalMod}\)
  • Conjugation key comes into play after \(\text{CoeffToSlot}\), since its result may contain complex values. Before evaluating the polynomial in \(\text{EvalMod}\), the ciphertext must be separated into real and imaginary parts, which requires the conjugation key.

But what exactly is the \(\text{LevelRecover}\) step? LevelRecover is, in fact, an essential part of bootstrapping. While a detailed explanation is out of scope for now, the basic idea is this: It lifts a message at a lower level (e.g., x mod q) to a higher level representation (e.g., x + qI mod Q). At this point, although the level is restored, the message now contains unwanted integer noise terms (qI). If the message x is known to lie within the range (−1,1), applying a mod 1 function can recover the correct x mod Q.

However, we cannot directly evaluate the mod 1 function right after \(\text{LevelRecover}\), because that step exists only in the coefficient-encoded domain. Therefore, it must be wrapped between \(\text{SlotToCoeff}\) and \(\text{CoeffToSlot}\) to convert the message to and from the coefficient space appropriately.

Now that we've explored how bootstrapping restores the ciphertext level, you should have a general idea of the overall process. While we didn’t go into the full details of matrix and polynomial evaluations—both of which are computationally expensive operations—they play a major role in bootstrapping. Fortunately, HEaaN includes many optimizations that make these heavy computations practically applicable.

Starting from the next chapter, we’ll move beyond basic API usage and dive into more advanced topics that will help you apply HEaaN in more flexible and powerful ways. We hope this helps you get even more comfortable with HEaaN!


Copyright ©️ 2025 CryptoLab, Inc. All rights reserved.

No portion of this book may be reproduced in any form without written permission from CryptoLab, Inc.


  1. acronym for Grande