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

**URL:** <https://openfhe.discourse.group/t/bug-report-128-bit-ckks-complex-encoding-uses-a-64-bit-shift-in-the-imaginary-branch-and-can-misencode-results/2358>\
**Category:** Bug Reports\
**Tags:** bugs\
**Created:** [August 3, 2026, 3:09pm UTC](https://openfhe.discourse.group/t/bug-report-128-bit-ckks-complex-encoding-uses-a-64-bit-shift-in-the-imaginary-branch-and-can-misencode-results/2358 "2026-08-03T15:09:05Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![CCYJ](https://avatars.discourse-cdn.com/v4/letter/c/b5ac83/32.png) [@CCYJ](https://openfhe.discourse.group/u/CCYJ)\
**Post date:** [August 3, 2026, 3:09pm UTC](https://openfhe.discourse.group/t/bug-report-128-bit-ckks-complex-encoding-uses-a-64-bit-shift-in-the-imaginary-branch-and-can-misencode-results/2358/1 "2026-08-03T15:09:05Z")

</div>

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:

```cpp
((int128_t)1) << pRemaining

```

but the corresponding imaginary branch uses a signed 64-bit constant:

```cpp
(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:

```plaintext
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:

```bash
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.

```cpp
#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:

```plaintext
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:

```cpp
int128_t pPowRemaining = (static_cast<int64_t>(1)) << pRemaining;

```

to:

```cpp
int128_t pPowRemaining = (static_cast<int128_t>(1)) << pRemaining;

```

the same program no longer triggers UBSan and decodes:

```plaintext
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:

```cpp
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:

```cpp
pRemaining = pCurrent + n2;

```

The real branch uses a 128-bit constant:

```cpp
int128_t pPowRemaining = ((int128_t)1) << pRemaining;

```

but the imaginary branch uses:

```cpp
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:

```cpp
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()`:

```cpp
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`

- `src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp`

- `src/pke/include/scheme/ckksrns/gen-cryptocontext-ckksrns-internal.h`

- `src/pke/examples/advanced-real-numbers-128.cpp`

- `src/pke/include/encoding/ckkspackedencoding.h`

# **Suggested direction**

The immediate type fix seems straightforward:

```cpp
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:

```cpp
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:

```cpp
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

---

<div class="post-metadata">

**Author:** ![ypolyakov](https://yyz1.discourse-cdn.com/flex031/user_avatar/openfhe.discourse.group/ypolyakov/32/47_2.png) [@ypolyakov](https://openfhe.discourse.group/u/ypolyakov)\
**Post date:** [August 3, 2026, 7:32pm UTC](https://openfhe.discourse.group/t/bug-report-128-bit-ckks-complex-encoding-uses-a-64-bit-shift-in-the-imaginary-branch-and-can-misencode-results/2358/2 "2026-08-03T19:32:55Z")

</div>

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](https://github.com/openfheorg/openfhe-development/issues/1235)
