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.