Hi OpenFHE team,
I would like to report a correctness bug in the NATIVEINT == 128 CKKS complex-encoding path.
Summary
In the 128-bit CKKS encoder, the real branch scales with a 128-bit constant:
((int128_t)1) << pRemaining
but the corresponding imaginary branch uses a signed 64-bit constant:
(static_cast<int64_t>(1)) << pRemaining
I reproduced this locally on an instrumented OpenFHE checkout that still reports OPENFHE_VERSION=1.5.1, with NATIVE_SIZE=128, MATHBACKEND=4, and CKKSDataType = COMPLEX.
With scalingModSize = 90 and an input vector whose imaginary part is 2^25, the same preprocessing used inside CKKSPackedEncoding::Encode() yields max_imag_pRemaining = 64, and UBSan reports:
runtime error: shift exponent 64 is too large for 64-bit type 'int64_t'
The same run also produces a wrong decoded result: the input imaginary value 33554432 comes back as approximately 1.8189894e-12.
As a control, I locally changed only the buggy line from int64_t to int128_t and reran the same program. With that one-line change, the same input decoded back to the expected imaginary value 33554432.
Source inspection also found the same copied pattern in FHECKKSRNS::MakeAuxPlaintext(), so the issue is not isolated to the ordinary CKKS packed-encoding path. I have not dynamically exercised that second path in this report.
As of August 3, 2026, source inspection of the upstream main branch still
shows the same 64-bit imaginary-branch shift expression in both files.
Environment
- OpenFHE release/tag referenced by the source links below:
v1.5.1 - Official
v1.5.1release commit:1306d14f8c26bb6150d3e6ad54f28dfe1007689e - Local checkout used for dynamic reproduction:
ed361af22049007db2107e7c69bcff209e8c420d - Configuration:
NATIVE_SIZE=128,MATHBACKEND=4 - CKKS data type:
COMPLEX - OS: Linux x86_64
- Compiler:
clang 14.0.0 - Sanitizers:
AddressSanitizer,UndefinedBehaviorSanitizer
Representative build command:
cmake -S openfhe-development -B build-openfhe-128-asan \
-DNATIVE_SIZE=128 \
-DMATHBACKEND=4 \
-DCMAKE_BUILD_TYPE=Debug \
-DBUILD_UNITTESTS=OFF \
-DBUILD_EXAMPLES=OFF \
-DBUILD_BENCHMARKS=OFF \
-DWITH_OPENMP=OFF \
-DCMAKE_CXX_FLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer -g -O0' \
-DCMAKE_C_FLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer -g -O0'
cmake --build build-openfhe-128-asan -j
Minimal reproduction
The reproducer below mirrors the encoder’s own preprocessing once to print the effective imaginary-branch shift and then calls the normal public CKKS API.
COMPLEX mode is important here: in default REAL mode, the CKKS plaintext constructor zeroes all imaginary parts before encoding.
#include "openfhe.h"
#include "math/dftransform.h"
#include <cmath>
#include <complex>
#include <iostream>
#include <limits>
#include <vector>
using namespace lbcrypto;
static int MaxImagPRemaining(const CryptoContext<DCRTPoly>& cc,
const std::vector<std::complex<double>>& values,
uint64_t pBits) {
auto inverse = values;
inverse.resize(values.size());
DiscreteFourierTransform::FFTSpecialInv(inverse, cc->GetRingDimension() * 2);
const int32_t pCurrent = static_cast<int32_t>(pBits - 52);
int maxValue = std::numeric_limits<int>::min();
for (const auto& coeff : inverse) {
int n2 = 0;
(void)std::frexp(coeff.imag(), &n2);
maxValue = std::max(maxValue, pCurrent + n2);
}
return maxValue;
}
int main() {
CCParams<CryptoContextCKKSRNS> parameters;
parameters.SetMultiplicativeDepth(1);
parameters.SetScalingModSize(90);
parameters.SetFirstModSize(90);
parameters.SetBatchSize(8);
parameters.SetCKKSDataType(COMPLEX);
auto cc = GenCryptoContext(parameters);
cc->Enable(PKE);
std::vector<std::complex<double>> values(8, {0.0, std::ldexp(1.0, 25)});
std::cout << "max_imag_pRemaining="
<< MaxImagPRemaining(cc, values, 90) << std::endl;
auto plaintext = cc->MakeCKKSPackedPlaintext(values);
auto keys = cc->KeyGen();
auto ctxt = cc->Encrypt(keys.publicKey, plaintext);
Plaintext recovered;
cc->Decrypt(keys.secretKey, ctxt, &recovered);
recovered->SetLength(8);
std::cout << "decoded=" << recovered << std::endl;
return 0;
}
Expected behavior
The 128-bit CKKS complex-encoding path should not use a narrower shift type in the imaginary branch than it uses in the real branch.
For this input, the encoder should either:
- encode the imaginary component correctly; or
- reject an unsupported shift range explicitly before performing an invalid shift.
The same expectation applies to the duplicated 128-bit path in FHECKKSRNS::MakeAuxPlaintext().
Actual behavior
With the source as-is, the reproducer prints:
max_imag_pRemaining=64
/home/sht/agent-fuzzing/openfhe-development/src/pke/lib/encoding/ckkspackedencoding.cpp:178:64:
runtime error: shift exponent 64 is too large for 64-bit type 'int64_t' (aka 'long')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior
/home/sht/agent-fuzzing/openfhe-development/src/pke/lib/encoding/ckkspackedencoding.cpp:178:64 in
decoded=( (-2.3607095e-24, 1.8189894e-12), (3.6531639e-25, 1.8189894e-12), ... )
So the same public API call both:
- reaches undefined behavior in the imaginary scaling step; and
- produces a decoded imaginary value near zero instead of the original
33554432.
As a control, after changing only:
int128_t pPowRemaining = (static_cast<int64_t>(1)) << pRemaining;
to:
int128_t pPowRemaining = (static_cast<int128_t>(1)) << pRemaining;
the same program no longer triggers UBSan and decodes:
decoded=( (-1.3566195e-24, 33554432), (1.0731727e-24, 33554432), ... )
Impact
This is a correctness bug in the 128-bit CKKS complex-encoding path.
The confirmed impact is:
- undefined behavior in the public encoding path under UBSan; and
- wrong encoded/decrypted imaginary values for affected inputs.
The issue is configuration-specific:
NATIVEINT == 128- CKKS complex/plaintext handling that actually preserves imaginary parts
- inputs for which the imaginary branch reaches
pRemaining >= 63;
pRemaining = 63crosses the positive range ofint64_t, while
pRemaining >= 64also exceeds the 64-bit shift width
The same unsafe expression is also present in MakeAuxPlaintext(), so the auxiliary bootstrapping-plaintext path appears to be affected as well. I have not dynamically exercised that second path in this report.
Cause analysis
In CKKSPackedEncoding::Encode(), the 128-bit path computes:
uint64_t pBits = encodingParams->GetPlaintextModulus();
uint32_t precision = 52;
int32_t pCurrent = pBits - precision;
and then derives the remaining shift from the exponent of the transformed coefficient:
pRemaining = pCurrent + n2;
The real branch uses a 128-bit constant:
int128_t pPowRemaining = ((int128_t)1) << pRemaining;
but the imaginary branch uses:
int128_t pPowRemaining = (static_cast<int64_t>(1)) << pRemaining;
So even though the surrounding code is in the NATIVEINT == 128 path and stores the result in int128_t, the shift itself is still performed in 64-bit signed arithmetic.
For the reproduced configuration:
scalingModSize = 90, sopCurrent = 90 - 52 = 38- the input
imag = 2^25yieldsmax_imag_pRemaining = 64after the sameFFTSpecialInv + frexppreprocessing used byEncode()
This makes the imaginary shift step invalid in the current implementation.
The overflow guard:
if (is128BitOverflow(dre) || is128BitOverflow(dim)) {
OPENFHE_THROW("Overflow, try to decrease scaling factor");
}
does not prevent this because it checks the 52-bit mantissa-scaled doubles dre / dim, not the later shift count pRemaining.
I found the same copied bug pattern in FHECKKSRNS::MakeAuxPlaintext():
int128_t pPowRemaining = ((int128_t)1) << pRemaining; // real
...
int128_t pPowRemaining = (static_cast<int64_t>(1)) << pRemaining; // imag
so the underlying issue appears to be a duplicated type mismatch rather than a one-off typo in only one file.
Relevant source locations
-
src/pke/lib/encoding/ckkspackedencoding.cpp- 128-bit CKKS encoding path: lines 134-179
- real branch uses
int128_t: line 167 - imaginary branch uses
int64_t: line 178 - GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/pke/lib/encoding/ckkspackedencoding.cpp#L134-L179
-
src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp- 128-bit
FHECKKSRNS::MakeAuxPlaintext()path: lines 2380-2525 - real branch uses
int128_t: line 2465 - imaginary branch uses
int64_t: line 2476 - GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp#L2380-L2525
- 128-bit
-
src/pke/include/scheme/ckksrns/gen-cryptocontext-ckksrns-internal.hscalingModSizeis stored inEncodingParamsImplas the plaintext-modulus field used by CKKS encoding: line 95- GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/pke/include/scheme/ckksrns/gen-cryptocontext-ckksrns-internal.h#L95-L95
-
src/pke/examples/advanced-real-numbers-128.cpp- example configuration using
SetScalingModSize(90): lines 154-158 - GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/pke/examples/advanced-real-numbers-128.cpp#L154-L158
- example configuration using
-
src/pke/include/encoding/ckkspackedencoding.h- in default
REALmode, the constructor zeroes imaginary parts: lines 97-101 - GitHub link: https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/pke/include/encoding/ckkspackedencoding.h#L88-L102
- in default
Suggested direction
The immediate type fix seems straightforward:
int128_t pPowRemaining = (static_cast<int128_t>(1)) << pRemaining;
in both affected imaginary branches.
That correction fixes the confirmed case here. For complete hardening, the implementation should also validate the left-shift range in the pRemaining >= 0 branch before shifting, for example:
if (pRemaining >= 127) {
OPENFHE_THROW("invalid CKKS scaling shift");
}
The negative-pRemaining case uses a separate right-shift branch and should
not be rejected by the positive-shift guard. For complete hardening, that
branch should also validate the right-shift count or define the intended
underflow-to-zero behavior for very negative exponents.
It would also be safer to verify that the shifted mantissa still fits in int128_t before performing:
im = pPowRemaining * im64;
because a signed 128-bit multiplication overflow would occur before any later post-multiplication check can react to it.
Given that the real and imaginary branches are intended to be symmetric, a small shared helper for the 128-bit mantissa-scaling step would likely be safer than keeping two hand-written copies.
The narrow type correction is needed in both imaginary branches. Any shared shift-range and multiplication-overflow hardening should cover both real and imaginary scaling branches in both functions.
Reported by Jiang Chao, Beijing University of Posts and Telecommunications