[Bug report] 128-bit CKKS complex encoding uses a 64-bit shift in the imaginary branch and can misencode results

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.1 release 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 = 63 crosses the positive range of int64_t, while
    pRemaining >= 64 also 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, so pCurrent = 90 - 52 = 38
  • the input imag = 2^25 yields max_imag_pRemaining = 64 after the same FFTSpecialInv + frexp preprocessing used by Encode()

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

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

Thank you for reporting it. Added issue 128-bit CKKS complex encoding uses a 64-bit shift in the imaginary branch and can misencode results · Issue #1235 · openfheorg/openfhe-development · GitHub