Files
fhe/evaluator.go
T

353 lines
12 KiB
Go
Raw Normal View History

2026-01-03 19:11:38 -08:00
// Copyright (c) 2025, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
package fhe
import (
"fmt"
"github.com/luxfi/lattice/v7/core/rgsw/blindrot"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/ring"
)
// Evaluator evaluates boolean gates on encrypted data
// SECURITY: This evaluator does NOT require the secret key.
// It uses sample extraction and key switching for bootstrapping.
type Evaluator struct {
params Parameters
eval *blindrot.Evaluator
bsk *BootstrapKey
ringQLWE *ring.Ring
ringQBR *ring.Ring
// Key switching evaluator (BR -> LWE)
ksEval *rlwe.Evaluator
}
// NewEvaluator creates a new evaluator with bootstrap key.
// SECURITY: No secret key is required - bootstrapping uses public key switching.
func NewEvaluator(params Parameters, bsk *BootstrapKey) *Evaluator {
// Create key switching evaluator using the key switch key in bootstrap key
var ksEval *rlwe.Evaluator
if bsk.KSK != nil {
ksEval = rlwe.NewEvaluator(params.paramsBR, nil)
}
return &Evaluator{
params: params,
eval: blindrot.NewEvaluator(params.paramsBR, params.paramsLWE),
bsk: bsk,
ringQLWE: params.paramsLWE.RingQ(),
ringQBR: params.paramsBR.RingQ(),
ksEval: ksEval,
}
}
// sampleExtractAndModSwitch extracts the result from an RLWE ciphertext
// after blind rotation and converts it to an LWE ciphertext.
//
// When LWE and BR use the same dimension and modulus (recommended configuration),
// this simply returns the ciphertext directly since no conversion is needed.
//
// SECURITY: This operation does NOT require the secret key. The ciphertext
// remains encrypted throughout.
func (eval *Evaluator) sampleExtractAndModSwitch(ctBR *rlwe.Ciphertext) (*Ciphertext, error) {
// When same dimension and modulus, just return the ciphertext directly
// The key is the same, so no conversion needed
if eval.params.N() == eval.params.NBR() && eval.params.QLWE() == eval.params.QBR() {
// Copy to a new ciphertext in LWE parameters (same as BR here)
result := ctBR.CopyNew()
return &Ciphertext{result}, nil
}
// Different dimensions/moduli require modulus switching
levelBR := ctBR.Level()
ringQBR := eval.ringQBR.AtLevel(levelBR)
qBR := eval.params.QBR()
qLWE := eval.params.QLWE()
// Ensure we're working in coefficient domain
c0 := ctBR.Value[0].CopyNew()
c1 := ctBR.Value[1].CopyNew()
if ctBR.IsNTT {
ringQBR.INTT(*c0, *c0)
ringQBR.INTT(*c1, *c1)
}
// Create output ciphertext in LWE parameters
nLWE := eval.params.N()
ctLWE := rlwe.NewCiphertext(eval.params.paramsLWE, 1, eval.params.paramsLWE.MaxLevel())
scaleFactor := float64(qLWE) / float64(qBR)
// Scale and copy first N_LWE coefficients
for i := 0; i < nLWE; i++ {
scaled0 := uint64(float64(c0.Coeffs[0][i])*scaleFactor + 0.5)
scaled1 := uint64(float64(c1.Coeffs[0][i])*scaleFactor + 0.5)
ctLWE.Value[0].Coeffs[0][i] = scaled0 % qLWE
ctLWE.Value[1].Coeffs[0][i] = scaled1 % qLWE
}
// Convert to NTT
ringQLWE := eval.ringQLWE.AtLevel(eval.params.paramsLWE.MaxLevel())
ringQLWE.NTT(ctLWE.Value[0], ctLWE.Value[0])
ringQLWE.NTT(ctLWE.Value[1], ctLWE.Value[1])
ctLWE.IsNTT = true
return &Ciphertext{ctLWE}, nil
}
// bootstrap performs programmable bootstrapping with the given test polynomial
// and returns a fresh LWE ciphertext with the result.
//
// SECURITY: This implementation does NOT decrypt - it uses sample extraction
// and key switching, which are public operations on ciphertexts.
func (eval *Evaluator) bootstrap(ct *Ciphertext, testPoly *ring.Poly) (*Ciphertext, error) {
// Create map for single slot evaluation
testPolyMap := map[int]*ring.Poly{0: testPoly}
// Step 1: Evaluate blind rotation
// This produces an RLWE ciphertext under SKBR with the test polynomial
// evaluated at the encrypted value
results, err := eval.eval.Evaluate(ct.Ciphertext, testPolyMap, eval.bsk.BRK)
if err != nil {
return nil, fmt.Errorf("bootstrap: %w", err)
}
// Extract result for slot 0
ctBR, ok := results[0]
if !ok {
return nil, fmt.Errorf("bootstrap: no result for slot 0")
}
// Step 2: Sample extract and modulus switch
// This extracts the result and scales to the LWE modulus
return eval.sampleExtractAndModSwitch(ctBR)
}
// addCiphertexts adds two ciphertexts element-wise
func (eval *Evaluator) addCiphertexts(ct1, ct2 *Ciphertext) *Ciphertext {
result := rlwe.NewCiphertext(eval.params.paramsLWE, 1, ct1.Level())
eval.ringQLWE.Add(ct1.Value[0], ct2.Value[0], result.Value[0])
eval.ringQLWE.Add(ct1.Value[1], ct2.Value[1], result.Value[1])
result.IsNTT = ct1.IsNTT
return &Ciphertext{result}
}
// doubleCiphertext multiplies a ciphertext by 2 (element-wise addition with itself)
// This is key to OpenFHE's optimized XOR: 2*(ct1+ct2) causes wrap-around for (T,T) case
func (eval *Evaluator) doubleCiphertext(ct *Ciphertext) *Ciphertext {
result := rlwe.NewCiphertext(eval.params.paramsLWE, 1, ct.Level())
eval.ringQLWE.Add(ct.Value[0], ct.Value[0], result.Value[0])
eval.ringQLWE.Add(ct.Value[1], ct.Value[1], result.Value[1])
result.IsNTT = ct.IsNTT
return &Ciphertext{result}
}
// negateCiphertext negates a ciphertext
func (eval *Evaluator) negateCiphertext(ct *Ciphertext) *Ciphertext {
result := rlwe.NewCiphertext(eval.params.paramsLWE, 1, ct.Level())
eval.ringQLWE.Neg(ct.Value[0], result.Value[0])
eval.ringQLWE.Neg(ct.Value[1], result.Value[1])
result.IsNTT = ct.IsNTT
return &Ciphertext{result}
}
// addConstant adds a scalar constant to the ciphertext's constant term (b)
// This is used for gate offsets like OpenFHE's gate constants
func (eval *Evaluator) addConstant(ct *Ciphertext, constant uint64) *Ciphertext {
result := ct.CopyNew()
// Add constant to the constant term (coefficient 0 of polynomial b)
// Need to handle NTT form
if result.IsNTT {
eval.ringQLWE.INTT(result.Value[1], result.Value[1])
}
q := eval.params.QLWE()
result.Value[1].Coeffs[0][0] = (result.Value[1].Coeffs[0][0] + constant) % q
if ct.IsNTT {
eval.ringQLWE.NTT(result.Value[1], result.Value[1])
}
return &Ciphertext{result}
}
// ========== Boolean Gates ==========
// NOT computes the logical NOT of the input
// NOT(a) = 1 - a (free operation - just negate)
func (eval *Evaluator) NOT(ct *Ciphertext) *Ciphertext {
return eval.negateCiphertext(ct)
}
// AND computes the logical AND of two inputs
// AND(a, b) = 1 if a + b >= 1.5 (both are 1)
func (eval *Evaluator) AND(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
return eval.bootstrap(sum, eval.bsk.TestPolyAND)
}
// OR computes the logical OR of two inputs
// OR(a, b) = 1 if a + b >= 0.5 (at least one is 1)
func (eval *Evaluator) OR(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
return eval.bootstrap(sum, eval.bsk.TestPolyOR)
}
// XOR computes the logical XOR of two inputs
// Optimized algorithm matching OpenFHE: 2*(ct1 + ct2) with single bootstrap
// The doubling causes (T,T) → 2*0.25 = 0.5 to wrap around to -0.5,
// making the XOR test polynomial correctly return FALSE for both (T,T) and (F,F)
func (eval *Evaluator) XOR(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
doubled := eval.doubleCiphertext(sum) // Key: 2*(ct1+ct2)
return eval.bootstrap(doubled, eval.bsk.TestPolyXOR)
}
// NAND computes the logical NAND of two inputs
func (eval *Evaluator) NAND(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
return eval.bootstrap(sum, eval.bsk.TestPolyNAND)
}
// NOR computes the logical NOR of two inputs
func (eval *Evaluator) NOR(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
return eval.bootstrap(sum, eval.bsk.TestPolyNOR)
}
// XNOR computes the logical XNOR of two inputs
// Optimized algorithm matching OpenFHE: 2*(ct1 + ct2) with single bootstrap
// Same as XOR but with inverted test polynomial
func (eval *Evaluator) XNOR(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
doubled := eval.doubleCiphertext(sum) // Key: 2*(ct1+ct2)
return eval.bootstrap(doubled, eval.bsk.TestPolyXNOR)
}
2026-01-25 13:58:30 -08:00
// CMPCOMBINE computes: isLess OR (isEqual AND bitLt) in one bootstrap
// This is an optimized gate for comparison propagation.
// Uses weighted encoding: 2*isLess + isEqual + bitLt, threshold at 0
func (eval *Evaluator) CMPCOMBINE(isLess, isEqual, bitLt *Ciphertext) (*Ciphertext, error) {
// Compute weighted sum: 2*isLess + isEqual + bitLt
doubledIsLess := eval.doubleCiphertext(isLess)
sum := eval.addCiphertexts(doubledIsLess, isEqual)
sum = eval.addCiphertexts(sum, bitLt)
return eval.bootstrap(sum, eval.bsk.TestPolyCMPCOMBINE)
}
2026-01-03 19:11:38 -08:00
// ANDNY computes AND with negated first input: AND(NOT(a), b)
func (eval *Evaluator) ANDNY(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
return eval.AND(eval.NOT(ct1), ct2)
}
// ANDYN computes AND with negated second input: AND(a, NOT(b))
func (eval *Evaluator) ANDYN(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
return eval.AND(ct1, eval.NOT(ct2))
}
// ORNY computes OR with negated first input: OR(NOT(a), b)
func (eval *Evaluator) ORNY(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
return eval.OR(eval.NOT(ct1), ct2)
}
// ORYN computes OR with negated second input: OR(a, NOT(b))
func (eval *Evaluator) ORYN(ct1, ct2 *Ciphertext) (*Ciphertext, error) {
return eval.OR(ct1, eval.NOT(ct2))
}
// MUX computes the multiplexer: if sel then a else b
// MUX(sel, a, b) = (sel AND a) OR (NOT(sel) AND b)
func (eval *Evaluator) MUX(sel, ctTrue, ctFalse *Ciphertext) (*Ciphertext, error) {
selAndTrue, err := eval.AND(sel, ctTrue)
if err != nil {
return nil, err
}
notSelAndFalse, err := eval.AND(eval.NOT(sel), ctFalse)
if err != nil {
return nil, err
}
return eval.OR(selAndTrue, notSelAndFalse)
}
// ========== Multi-Input Gates ==========
//
// Note: Single-bootstrap multi-input gates require careful offset tuning
// matching OpenFHE's gate constants. For correctness, we use 2-bootstrap
// composition here. Future optimization could add single-bootstrap versions.
// AND3 computes the logical AND of three inputs
// AND3(a, b, c) = AND(AND(a, b), c)
func (eval *Evaluator) AND3(ct1, ct2, ct3 *Ciphertext) (*Ciphertext, error) {
ab, err := eval.AND(ct1, ct2)
if err != nil {
return nil, err
}
return eval.AND(ab, ct3)
}
// OR3 computes the logical OR of three inputs
// OR3(a, b, c) = OR(OR(a, b), c)
func (eval *Evaluator) OR3(ct1, ct2, ct3 *Ciphertext) (*Ciphertext, error) {
ab, err := eval.OR(ct1, ct2)
if err != nil {
return nil, err
}
return eval.OR(ab, ct3)
}
// MAJORITY computes the majority vote of three inputs with single bootstrap
// MAJORITY(a, b, c) = 1 if at least two inputs are 1
// This uses a single bootstrap since the threshold at 0 correctly separates
// 0-1 true inputs (sum < 0) from 2-3 true inputs (sum > 0)
func (eval *Evaluator) MAJORITY(ct1, ct2, ct3 *Ciphertext) (*Ciphertext, error) {
sum := eval.addCiphertexts(ct1, ct2)
sum = eval.addCiphertexts(sum, ct3)
return eval.bootstrap(sum, eval.bsk.TestPolyMAJORITY)
}
// NAND3 computes the logical NAND of three inputs
// NAND3(a, b, c) = NOT(AND3(a, b, c))
func (eval *Evaluator) NAND3(ct1, ct2, ct3 *Ciphertext) (*Ciphertext, error) {
result, err := eval.AND3(ct1, ct2, ct3)
if err != nil {
return nil, err
}
return eval.NOT(result), nil
}
// NOR3 computes the logical NOR of three inputs
// NOR3(a, b, c) = NOT(OR3(a, b, c))
func (eval *Evaluator) NOR3(ct1, ct2, ct3 *Ciphertext) (*Ciphertext, error) {
result, err := eval.OR3(ct1, ct2, ct3)
if err != nil {
return nil, err
}
return eval.NOT(result), nil
}
// Copy creates a copy of a ciphertext
func (eval *Evaluator) Copy(ct *Ciphertext) *Ciphertext {
return &Ciphertext{ct.CopyNew()}
}
// Refresh bootstraps a ciphertext to reduce noise
func (eval *Evaluator) Refresh(ct *Ciphertext) (*Ciphertext, error) {
return eval.bootstrap(ct, eval.bsk.TestPolyID)
}