[Bug report] Field2n(const Poly&) can flip the sign of positive centered coefficients above `INT64_MAX`

Hi OpenFHE team,
I would like to report a reproducible correctness bug in the public Field2n(const Poly&) constructor.

When a Poly uses a modulus larger than 2^64 and contains a coefficient on the positive side of the centered representation that is larger than INT64_MAX, Field2n can flip the sign of that coefficient while converting it to std::complex<double>.

Summary

Field2n(const Poly&) is a public constructor:

  • src/core/include/lattice/field2n.h: lines 86-92

In the positive branch, the implementation converts the coefficient with ConvertToInt() and then narrows it to int64_t before converting to double:

static_cast<double>(static_cast<int64_t>(element[i].ConvertToInt()))

On the tested backend configuration (NATIVE_SIZE=64, MATHBACKEND=4), BasicInteger is uint64_t, so ConvertToInt() returns uint64_t.

This is incorrect for residues whose mathematical centered representatives lie in the range:

INT64_MAX < centered_value = coefficient <= floor(modulus / 2)

because the centered representative is still positive, but the narrowing conversion to int64_t can produce an implementation-defined result. With Clang 14 on the tested x86-64 configuration, 9223372036854775808 becomes -9223372036854775808 before the final cast to double.

I reproduced this on an OpenFHE main-branch checkout at revision:

  • tested revision: ed361af22049007db2107e7c69bcff209e8c420d

The affected source file matches the official v1.5.1 release for this code path, and the same expression is present there as well.

Environment

  • OpenFHE tested revision: ed361af22049007db2107e7c69bcff209e8c420d
  • Matching public release for this code path: v1.5.1
  • Backend configuration: MATHBACKEND=4, NATIVE_SIZE=64
  • Library configuration: BUILD_STATIC=ON, BUILD_SHARED=OFF
  • OS: Linux x86_64
  • Compiler: Clang 14.0.0

Minimal reproduction

The following program uses only public core types. It creates a Poly with valid order-8 parameters:

  • modulus q = 18446744073709551697
  • root of unity w = 15713903524825792581
  • coefficient a = 9223372036854775808 = 2^63

Since:

floor(q / 2) = 9223372036854775848

the mathematical centered representative is still positive.

The supplied root is a primitive order-8 root modulo q:

w^4 mod q = q - 1
w^8 mod q = 1
#include "lattice/field2n.h"
#include "openfhecore.h"

#include <cmath>
#include <cstdint>
#include <iostream>
#include <memory>

using namespace lbcrypto;

int main() {
    const BigInteger modulus("18446744073709551697");
    const BigInteger rootOfUnity("15713903524825792581");
    const BigInteger coeff("9223372036854775808");

    auto params = std::make_shared<ILParams>(8, modulus, rootOfUnity);
    Poly poly(params, Format::COEFFICIENT, true);
    poly[0] = coeff;

    Field2n field(poly);

    const double observed = field[0].real();
    const double mathematicalCenteredValue = 9223372036854775808.0;

    std::cout << "modulus=" << modulus << std::endl;
    std::cout << "coeff=" << coeff << std::endl;
    std::cout << "threshold=" << (modulus / BigInteger(2)) << std::endl;
    std::cout << "observed_double=" << observed << std::endl;
    std::cout << "mathematical_centered_value="
              << mathematicalCenteredValue << std::endl;
    std::cout << "sign_corrupted=" << (std::signbit(observed) ? 1 : 0)
              << std::endl;
    std::cout << "match=" << ((observed == mathematicalCenteredValue) ? 1 : 0)
              << std::endl;

    return std::signbit(observed) ? 1 : 0;
}

A minimal CMakeLists.txt used for this probe is:

cmake_minimum_required(VERSION 3.16)
project(openfhe_field2n_sign_probe CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(OPENFHE_SRC_DIR "/path/to/openfhe-development")
set(OPENFHE_BUILD_DIR "/path/to/openfhe-build")

add_executable(openfhe_field2n_sign_probe
    openfhe_field2n_sign_probe.cpp)

target_include_directories(openfhe_field2n_sign_probe PRIVATE
    "${OPENFHE_SRC_DIR}/third-party/include"
    "${OPENFHE_SRC_DIR}/third-party/cereal/include"
    "${OPENFHE_SRC_DIR}/src/core/include"
    "${OPENFHE_BUILD_DIR}/src/core"
    "${OPENFHE_SRC_DIR}/src/core/lib")

target_link_libraries(openfhe_field2n_sign_probe PRIVATE
    "${OPENFHE_BUILD_DIR}/lib/libOPENFHEcore_static.a"
    pthread
    dl
    m)

The OpenFHE library build used by this probe was configured with:

cmake -S /path/to/openfhe-development \
  -B /path/to/openfhe-build \
  -DCMAKE_CXX_COMPILER=clang++-14 \
  -DBUILD_STATIC=ON \
  -DBUILD_SHARED=OFF \
  -DMATHBACKEND=4 \
  -DNATIVE_SIZE=64

Representative build steps:

cmake -S . -B build \
  -DCMAKE_CXX_COMPILER=clang++-14

cmake --build build -j

./build/openfhe_field2n_sign_probe

Actual behavior

The probe returns failure and prints:

coeff=9223372036854775808
threshold=9223372036854775848
observed_double=-9.22337e+18
mathematical_centered_value=9.22337e+18
sign_corrupted=1
match=0

So the mathematical centered representative is positive, but the constructor returns a negative floating-point value.

Expected behavior

The mathematical centered representative here is +2^63, which is exactly representable as double.

So the constructor does not need to lose information at the final floating-point step. The bug is the unchecked narrowing through int64_t before the conversion to double.

If this constructor only supports centered coefficients that fit in int64_t, it should reject larger positive centered representatives explicitly instead of silently flipping their sign.

Cause analysis

The public constructor is here:

  • src/core/include/lattice/field2n.h: lines 86-92

The problematic implementation is:

Field2n::Field2n(const Poly& element) : format(Format::COEFFICIENT) {
    ...
    BigInteger negativeThreshold(element.GetModulus() / Poly::Integer(2));
    for (size_t i = 0; i < size; ++i) {
        if (element[i] > negativeThreshold)
            ...
        else
            this->std::vector<std::complex<double>>::push_back(
                static_cast<double>(static_cast<int64_t>(element[i].ConvertToInt())));
    }
}

In the tested backend configuration (NATIVE_SIZE=64, MATHBACKEND=4), BasicInteger is uint64_t, and ConvertToInt() defaults to that backend type:

template <typename T = BasicInteger>
T ConvertToInt() const noexcept

So this sequence:

BigInteger(9223372036854775808)
  -> uint64_t(9223372036854775808)
  -> static_cast<int64_t>(...)
  -> static_cast<double>(...)

can produce an implementation-defined negative int64_t value before the final cast to double.

Impact

This is a result-integrity bug in a public conversion constructor.

It affects callers that pass residues whose mathematical centered representatives remain positive but fall outside the int64_t range. The public constructor neither documents an int64_t representability precondition nor detects that the centered value falls outside that intermediate type’s range.

I have directly confirmed on the tested configuration:

  • a positive centered coefficient becomes negative

Relevant source locations

Suggested direction

The constructor should not force positive centered coefficients through int64_t before converting them to double.

The implementation performs a centered conversion, but the public constructor does not document an int64_t representability precondition. Any range check should therefore happen while the magnitude is still represented as BigInteger, before any fixed-width narrowing.

If the constructor only supports centered coefficients inside the int64_t range, it should reject larger positive residues explicitly.

If larger positive centered values are intended to be accepted, the implementation should avoid this pattern:

static_cast<int64_t>(element[i].ConvertToInt())

and instead:

  • keep the sign and magnitude in BigInteger form;
  • use the existing int64_t fast path only after proving that the centered magnitude is at most INT64_MAX; and
  • otherwise fall back to BigInteger::ConvertToDouble() and apply the sign afterwards.

That would preserve the correct sign and the normal BigInteger-to-double rounding behavior without forcing the magnitude through an intermediate signed 64-bit integer.

Reported by Jiang Chao, Beijing University of Posts and Telecommunications