[Bug report] `ConvertToInt32` silently narrows centered values outside the int32_t range

Hi OpenFHE team,
I would like to report a reproducible correctness bug in OpenFHE’s public matrix conversion API:

Matrix<int32_t> ConvertToInt32(const Matrix<BigInteger>& input,
                               const BigInteger& modulus);

The function is documented as converting values from Z_q to the centered range [-q/2, q/2], but it can return a negative int32_t for a residue whose mathematical centered representative is positive but lies outside the int32_t range.

Summary

The public declarations are:

  • src/core/include/math/matrix.h: lines 785-795

The implementation uses ConvertToInt() and assigns directly into int32_t:

result(i, j) = input(i, j).ConvertToInt();

and similarly for Matrix<BigVector>.

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

If:

INT32_MAX < value <= floor(modulus / 2)

the value is still positive in centered form, but the assignment to int32_t can produce an implementation-defined narrowing result. With Clang 14 on the tested x86-64 configuration, 2147483648 becomes -2147483648.

I reproduced this on:

  • tested revision: ed361af22049007db2107e7c69bcff209e8c420d

The affected source file matches the v1.5.1 release for this code path.

The corresponding MatrixStrassen conversion helpers contain the same unchecked narrowing pattern. I have not separately reproduced those overloads in this report.

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 the public ConvertToInt32(...) API with:

  • modulus q = 4294967311
  • matrix entry a = 2147483648 = 2^31

Since:

floor(q / 2) = 2147483655

the mathematical centered representative is still positive.

#include "math/matrix.h"
#include "openfhecore.h"

#include <cstdint>
#include <iostream>

using namespace lbcrypto;

int main() {
    const BigInteger modulus("4294967311");
    const BigInteger coeff("2147483648");

    Matrix<BigInteger> input([]() { return BigInteger(0); }, 1, 1);
    input(0, 0) = coeff;

    Matrix<int32_t> output = ConvertToInt32(input, modulus);
    const int64_t mathematicalCenteredValue = 2147483648LL;
    const int64_t observed                  = static_cast<int64_t>(output(0, 0));

    std::cout << "modulus=" << modulus << std::endl;
    std::cout << "coeff=" << coeff << std::endl;
    std::cout << "threshold=" << (modulus / BigInteger(2)) << std::endl;
    std::cout << "observed_int32=" << observed << std::endl;
    std::cout << "mathematical_centered_value="
              << mathematicalCenteredValue << std::endl;
    std::cout << "sign_corrupted=" << (observed < 0 ? 1 : 0) << std::endl;
    return observed < 0 ? 1 : 0;
}

A minimal CMakeLists.txt used for this probe is:

cmake_minimum_required(VERSION 3.16)
project(openfhe_matrix_converttoint32_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_matrix_converttoint32_probe
    openfhe_matrix_converttoint32_probe.cpp)

target_include_directories(openfhe_matrix_converttoint32_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_matrix_converttoint32_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_matrix_converttoint32_probe

Actual behavior

The probe returns failure and prints:

modulus=4294967311
coeff=2147483648
threshold=2147483655
observed_int32=-2147483648
mathematical_centered_value=2147483648
sign_corrupted=1

So the mathematical centered representative is positive, but the API returns a negative int32_t.

Expected behavior

The mathematical centered representative here is +2147483648, which is not representable as int32_t.

Therefore, the current API cannot return the correct value for this input. It should either:

  • reject the conversion before narrowing, with a documented representability
    precondition; or
  • provide an API returning a wider signed type.

Cause analysis

The public declarations are:

  • src/core/include/math/matrix.h: lines 785-795

The implementation is:

Matrix<int32_t> ConvertToInt32(const Matrix<BigInteger>& input,
                               const BigInteger& modulus) {
    ...
    BigInteger negativeThreshold(modulus / BigInteger(2));
    ...
    if (input(i, j) > negativeThreshold) {
        result(i, j) = -1 * (modulus - input(i, j)).ConvertToInt();
    }
    else {
        result(i, j) = input(i, j).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 the positive branch effectively does:

BigInteger(2147483648)
  -> uint64_t(2147483648)
  -> unchecked conversion to int32_t
  -> implementation-defined result

without checking whether the centered value actually fits in signed 32-bit range.

Impact

This is a public result-integrity bug in a documented conversion helper.

It affects callers that pass residues whose mathematical centered representatives fall outside the int32_t range. The public API neither documents such inputs as invalid nor reports that the result is unrepresentable.

The confirmed impact on the tested configuration is:

  • silent sign corruption in the returned Matrix<int32_t>

I have not yet traced a higher-level cryptographic failure from this helper in this report, but this conversion API is part of the public math/lattice layer and can taint any downstream workflow that assumes the centered conversion is correct.

Relevant source locations

Suggested direction

If the intended contract is truly “convert centered values to int32_t”, the implementation should perform the representability check while the value, or its negative-side magnitude, is still represented as BigInteger, before calling ConvertToInt().

Only after proving that the mathematical centered representative lies in the range:

INT32_MIN <= centered <= INT32_MAX

should the implementation convert it to a fixed-width integer and assign it to the result matrix.

The negative boundary also requires special handling: a magnitude of 2^31 is valid because it represents INT32_MIN, even though positive 2^31 is not representable as int32_t.

Concretely, that means:

  • positive branch: require value <= INT32_MAX
  • negative branch: let magnitude = modulus - value, then require magnitude <= 2^31
  • if magnitude == 2^31, map that case specifically to INT32_MIN

If larger centered values are legitimate inputs, the API should return a wider type instead of silently narrowing into int32_t.

The same cleanup should also remove the current pattern of performing signed-valued centered arithmetic via unsigned ConvertToInt() results in the negative branch.

Reported by Jiang Chao, Beijing University of Posts and Telecommunications