Summary
EvalFHEWtoCKKS uses the modulus of the first LWE ciphertext to scale every ciphertext in its input vector. The public LWE API permits valid ciphertexts under the same key and plaintext modulus to use different ciphertext moduli, but the conversion accepts a mixed-modulus vector and silently returns an incorrect CKKS slot.
With plaintext modulus p=256, messages [1,2,3,4,1,2,3,4], and Q=2^25, homogeneous vectors encrypted at Q and Q/2 both convert correctly. Replacing only the fourth Q ciphertext with a valid Q/2 ciphertext changes the decoded slot for message 4 from about 3.9936 to about 1.9992 in the reproduction run. The returned ciphertext remains usable: encrypted +1 tracks the incorrect value within CKKS approximation error.
Environment
- Library version and commit: OpenFHE
v1.6.1, commitb59e1f291965f14fbee5172cd83a20f5b856204f. - Build and runtime: Linux x86_64; C++17.
- Test configuration: CKKS ring dimension 8192; FHEW plaintext modulus 256; LWE ciphertext moduli
Q=2^25andQ/2.
Minimal reproduction
The program encrypts every source message under the same FHEW key at both moduli and verifies each LWE ciphertext before conversion. It then compares the homogeneous controls with a vector containing one Q/2 ciphertext. The expected CKKS embedding is p/(2π) * sin(2πm/p).
Save the following files under repro/openfhe-fhew-to-ckks-mixed-moduli/. Point OpenFHE_DIR to the CMake package directory of an OpenFHE v1.6.1 installation, then build and run:
cmake -S repro/openfhe-fhew-to-ckks-mixed-moduli \
-B build/openfhe-fhew-to-ckks-mixed-moduli \
-DOpenFHE_DIR=/path/to/openfhe/lib/OpenFHE
cmake --build build/openfhe-fhew-to-ckks-mixed-moduli --parallel 2
build/openfhe-fhew-to-ckks-mixed-moduli/openfhe_fhew_to_ckks_mixed_moduli
CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(openfhe_fhew_to_ckks_mixed_moduli_repro CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(OpenFHE 1.6.1 EXACT CONFIG REQUIRED)
include_directories(
${OpenFHE_INCLUDE}
${OpenFHE_INCLUDE}/cereal
${OpenFHE_INCLUDE}/pke
${OpenFHE_INCLUDE}/core
${OpenFHE_INCLUDE}/binfhe)
link_directories(${OpenFHE_LIBDIR})
add_executable(openfhe_fhew_to_ckks_mixed_moduli main.cpp)
target_link_libraries(openfhe_fhew_to_ckks_mixed_moduli ${OpenFHE_SHARED_LIBRARIES})
main.cpp:
#include <algorithm>
#include <cmath>
#include <iostream>
#include <vector>
#include "binfhecontext.h"
#include "openfhe.h"
#include "utils/parallel.h"
using namespace lbcrypto;
static std::vector<double> Decode(const CryptoContext<DCRTPoly>& cc,
const PrivateKey<DCRTPoly>& sk,
const Ciphertext<DCRTPoly>& ct,
uint32_t slots) {
Plaintext plaintext;
cc->Decrypt(sk, ct, &plaintext);
plaintext->SetLength(slots);
auto values = plaintext->GetRealPackedValue();
values.resize(slots);
return values;
}
static double Expected(uint32_t message, uint32_t p) {
return static_cast<double>(p) / (2.0 * M_PI) *
std::sin(2.0 * M_PI * static_cast<double>(message) / static_cast<double>(p));
}
static double MaxError(const std::vector<double>& values,
const std::vector<LWEPlaintext>& messages,
uint32_t p) {
double error = 0.0;
for (size_t i = 0; i < messages.size(); ++i)
error = std::max(error, std::abs(values[i] - Expected(messages[i], p)));
return error;
}
int main() {
OpenFHEParallelControls.SetNumThreads(1);
constexpr uint32_t slots = 8;
constexpr uint32_t p = 256;
constexpr uint32_t logQ = 25;
CCParams<CryptoContextCKKSRNS> params;
params.SetRingDim(8192);
params.SetMultiplicativeDepth(16);
params.SetFirstModSize(60);
params.SetScalingModSize(50);
params.SetBatchSize(slots);
params.SetScalingTechnique(FLEXIBLEAUTOEXT);
params.SetSecurityLevel(HEStd_NotSet);
params.SetKeySwitchTechnique(HYBRID);
params.SetNumLargeDigits(3);
auto cc = GenCryptoContext(params);
cc->Enable(PKE);
cc->Enable(KEYSWITCH);
cc->Enable(LEVELEDSHE);
cc->Enable(ADVANCEDSHE);
cc->Enable(SCHEMESWITCH);
auto ckksKeys = cc->KeyGen();
auto lweContext = std::make_shared<BinFHEContext>();
lweContext->GenerateBinFHEContext(TOY, false, logQ, 0, GINX, false);
auto lweSecretKey = lweContext->KeyGen();
cc->EvalFHEWtoCKKSSetup(lweContext, slots, logQ);
cc->SetBinCCForSchemeSwitch(lweContext);
cc->EvalFHEWtoCKKSKeyGen(ckksKeys, lweSecretKey, slots, slots);
const NativeInteger q(uint64_t{1} << logQ);
const NativeInteger qHalf = q >> 1;
const std::vector<LWEPlaintext> messages{1, 2, 3, 4, 1, 2, 3, 4};
std::vector<LWECiphertext> atQ;
std::vector<LWECiphertext> atHalf;
for (const auto message : messages) {
auto high = lweContext->Encrypt(lweSecretKey, message, LARGE_DIM, p, q);
auto half = lweContext->Encrypt(lweSecretKey, message, LARGE_DIM, p, qHalf);
LWEPlaintext highDecoded = 0;
LWEPlaintext halfDecoded = 0;
lweContext->Decrypt(lweSecretKey, high, &highDecoded, p);
lweContext->Decrypt(lweSecretKey, half, &halfDecoded, p);
if (highDecoded != message || halfDecoded != message) {
std::cerr << "source LWE decryption failed for message=" << message << '\n';
return 2;
}
atQ.push_back(high);
atHalf.push_back(half);
}
const auto highOutput = cc->EvalFHEWtoCKKS(atQ, slots, slots, p, 0.0, static_cast<double>(p));
const auto halfOutput = cc->EvalFHEWtoCKKS(atHalf, slots, slots, p, 0.0, static_cast<double>(p));
auto mixed = atQ;
mixed[3] = atHalf[3];
const auto mixedOutput = cc->EvalFHEWtoCKKS(mixed, slots, slots, p, 0.0, static_cast<double>(p));
const auto highValues = Decode(cc, ckksKeys.secretKey, highOutput, slots);
const auto halfValues = Decode(cc, ckksKeys.secretKey, halfOutput, slots);
const auto mixedValues = Decode(cc, ckksKeys.secretKey, mixedOutput, slots);
const auto continuedValues = Decode(
cc, ckksKeys.secretKey, cc->EvalAdd(mixedOutput, 1.0), slots);
double continuedError = 0.0;
for (uint32_t i = 0; i < slots; ++i)
continuedError = std::max(continuedError,
std::abs(continuedValues[i] - mixedValues[i] - 1.0));
const double highError = MaxError(highValues, messages, p);
const double halfError = MaxError(halfValues, messages, p);
const double mixedError = std::abs(mixedValues[3] - Expected(4, p));
std::cout << "max_error_all_q=" << highError
<< " max_error_all_q_half=" << halfError
<< " mixed_slot3_expected=" << Expected(4, p)
<< " observed=" << mixedValues[3]
<< " error=" << mixedError
<< " post_add_error=" << continuedError << '\n';
if (highError > 0.05 || halfError > 0.05 || mixedError < 1.5 || continuedError > 0.02)
return 3;
std::cout << "REPRODUCED: mixed-modulus conversion returned a wrong CKKS slot\n";
return 1;
}
The program exits with status 1 after printing REPRODUCED when the mixed-vector mismatch occurs.
Expected behavior
Each source LWE ciphertext decrypts to its original message under the same key and plaintext modulus. EvalFHEWtoCKKS should therefore preserve the same documented CKKS embedding for every slot regardless of the per-ciphertext modulus. If mixed moduli are unsupported, the conversion should reject the vector rather than return a silently incorrect ciphertext.
Actual behavior
In a reproduced run, both homogeneous controls had maximum embedding error below 7e-5. With only slot 3 replaced by its valid Q/2 ciphertext, the expected value was 3.99358 and the decoded value was 1.9992, an error of 1.99437. The converted ciphertext remained usable: the error in the encrypted +1 continuation was 1.646e-7.
The mismatch also reproduces when the alternate-modulus ciphertext is created with the public LWE modulus-switch operation. An expanded position sweep found that an alternate modulus in the first vector position changes the batch scale and corrupts the other slots as well; replacing a later element corrupts that element.
Impact
A valid mixed-modulus LWE vector can convert successfully to CKKS while changing the represented value of one or more slots. Subsequent CKKS arithmetic remains operational but propagates the wrong values. Applications that batch ciphertexts without checking that their moduli match can therefore consume corrupted numerical results without a conversion error.
Cause analysis
The FHEW-to-CKKS implementation computes one prescale from LWECiphertexts[0]->GetModulus() and reuses it for every vector entry. It applies that scale to each ciphertext’s b value and to the homomorphic A*s transform. When a later ciphertext uses Q/2, its phase is interpreted using the first ciphertext’s Q scale, even though the source ciphertext itself decrypts correctly at its own modulus.