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
- Public constructor declarations:
https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/core/include/lattice/field2n.h#L82-L92 Field2n(const Poly&)implementation:
https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/core/include/lattice/field2n-impl.h#L54-L69Field2n(const NativePoly&)andField2n(const DCRTPoly&)contain the same explicitint64_tcast, although I have not reproduced an out-of-range case for those overloads under the testedNATIVE_SIZE=64configuration.
In that configuration, a native modulus cannot produce a positive centered representative aboveINT64_MAX:
https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/core/include/lattice/field2n-impl.h#L73-L110- Default
BasicInteger = uint64_ton the tested configuration:
https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/core/include/math/hal/basicint.h#L46-L58 - Default bigint
ConvertToInt()template:
https://github.com/openfheorg/openfhe-development/blob/v1.5.1/src/core/include/math/hal/bigintdyn/ubintdyn.h#L715-L727
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
BigIntegerform; - use the existing
int64_tfast path only after proving that the centered magnitude is at mostINT64_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