Files
fhe/pkg/threshold/partial_decrypt.go
T
Hanzo DevandGitHub 378b74758c feat(threshold): real Bendlin-Damgård M-of-N partial decrypt for LWE (#5)
Adds the canonical noise-flooding threshold-decryption primitives that
upstream consumers (notably luxfi/threshold/protocols/tfhe) need to
implement true M-of-N FHE decryption without any party holding the
master key.

  - ShareLWESecretKey: Shamir-splits the LWE secret coefficient-by-
    coefficient over Z_q. No party ever sees the master.
  - PartialDecryptLWE: party j computes d_j = c_1 · s_j + e_j with
    fresh smudging noise from a discrete Gaussian.
  - CombineLWE: integer-Lagrange (Bendlin-Damgård denominator clearing)
    + Δ^{-1} mod q to recover the plaintext polynomial.
  - SmudgingSigma: tight noise calibration against the worst-case
    integer Lagrange numerator, so Λ_max · σ · √t · 6 ≤ Q/16.

Decomplecting note: this LSSS-over-Z_q lives next to the existing
LSSS-over-RFC-3526-prime in pkg/threshold. The two are different
primitives — large-prime LSSS shares high-entropy secrets where the
secret space need not equal the FHE modulus; Z_q LSSS composes
directly with lattice arithmetic. Both stay in this package.

Decryption follows Bendlin-Damgård (TCC 2010, §4.2) and Asharov-Jain-
López-Alt-Tromer-Vaikuntanathan-Wichs (EUROCRYPT 2012, §4). Noise
flooding is the simulation-soundness mechanism.

Verified by round-trip tests at (t, n) ∈ {(2, 3), (5, 9), (11, 21)},
below-threshold negative test, share-doesn't-equal-master regression
guard, dedup guard, cross-parameter guard, and combine-determinism
test. All 8 new threshold tests pass.

Also adds public accessors:
  - fhe.Parameters.ParamsLWE/ParamsBR — expose underlying rlwe.Parameters
    so out-of-tree threshold callers can drive lattice primitives
    against the same parameter set the encryptor uses.
  - fhe.BitCiphertext.Bits / NewBitCiphertextFromBits — iterate per-bit
    for threshold decryption without round-tripping MarshalBinary.

References:
  - Bendlin, Damgård. Threshold Decryption and Zero-Knowledge Proofs
    for Lattice-Based Cryptosystems. TCC 2010.
  - Asharov, Jain, López-Alt, Tromer, Vaikuntanathan, Wichs. Multiparty
    Computation with Low Communication, Computation and Interaction
    via Threshold FHE. EUROCRYPT 2012.
  - Mouchet, Troncoso-Pastoriza, Bossuat, Hubaux. Multiparty
    Homomorphic Encryption from Ring-Learning-with-Errors. PETS 2021.
2026-05-29 12:07:05 -07:00

630 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// Real threshold-FHE partial-decrypt primitives.
//
// This file implements the M-of-N threshold decryption flow for the LWE
// ciphertexts produced by github.com/luxfi/fhe. The construction follows
// the standard "noise-flooding" threshold-FHE recipe:
//
// 1. Distributed key share: the LWE secret key s ∈ R_q (a polynomial in
// Z_q[X]/(X^N+1)) is Shamir-split coefficient-by-coefficient over the
// prime field Z_q. Party j gets a polynomial s_j whose i-th coefficient
// equals f_i(j), where f_i is a degree-(t-1) polynomial with f_i(0) =
// s[i]. No party ever holds s, and no t-1 parties together can
// recover s.
//
// 2. Partial decryption: given an LWE ciphertext ct = (c_0, c_1) (in NTT,
// with c_0 = -a·s + m + e and c_1 = a), party j computes
//
// d_j = c_1 · s_j + e_j (mod q, NTT domain)
//
// where e_j is fresh smudging noise sampled from a discrete Gaussian
// whose standard deviation is large enough to statistically hide the
// structure of c_1·s_j. The share s_j stays local.
//
// 3. Combine: collect ≥ t partials and compute
//
// c_0 + sum_j λ_j · d_j
// = c_0 + c_1 · (sum_j λ_j s_j) + sum_j λ_j e_j
// = c_0 + c_1 · s + sum_j λ_j e_j
// = m + e + sum_j λ_j e_j (mod q)
//
// where λ_j is the Lagrange basis coefficient at x=0 for the chosen
// subset of t parties. Rounding then recovers the bit. The combined
// noise sum_j λ_j e_j is the *original* fresh-encryption noise plus the
// smudging tail; the parameter set picks the smudging σ so the round-
// to-bit decision is correct with overwhelming probability and the
// simulator can produce d_j from a fresh encryption of the same bit
// under the master key (the AJL+12 / MTBH20 simulation-soundness
// argument).
//
// References:
// - Asharov, Jain, López-Alt, Tromer, Vaikuntanathan, Wichs.
// "Multiparty Computation with Low Communication, Computation and
// Interaction via Threshold FHE." EUROCRYPT 2012. ePrint 2011/613.
// - Mouchet, Troncoso-Pastoriza, Bossuat, Hubaux.
// "Multiparty Homomorphic Encryption from Ring-Learning-with-Errors."
// PETS 2021. ePrint 2020/304.
// - Boschini, Takahashi, Tibouchi. "Floppy-sized group signatures from
// lattices." (Noise-flooding parameter calibration.) ACNS 2024.
// - Bendlin, Damgård. "Threshold Decryption and Zero-Knowledge Proofs for
// Lattice-Based Cryptosystems." TCC 2010. (Lagrange combine over LWE.)
//
// Decomplecting note: LSSS-over-Z_q lives next to LSSS-over-large-prime in
// this package. They are *different primitives* — the large-prime version
// is for masking high-entropy secrets where the secret space need not match
// the FHE modulus (e.g., the SHA-256 hash of a serialized SKLWE used for
// verifiable secret sharing). This file's LSSS works directly in the LWE
// field Z_q so the Shamir math composes with the lattice arithmetic.
package threshold
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/ring"
"github.com/luxfi/lattice/v7/utils/sampling"
)
// Bendlin-Damgård denominator-clearing factor.
//
// When we apply the Lagrange interpolation formula λ_j = ∏_{k≠j} (-x_k)/(x_j - x_k)
// over the field Z_q to recombine the secret, the rational λ_j can be
// "small as an integer" but is computed modulo q — so the resulting
// uint64 in [0, q) may be huge. That huge multiplier, applied to the
// smudging noise e_j, destroys the rounding margin. The fix from
// Bendlin-Damgård (TCC 2010, §4.2) is to clear denominators by working
// with the scaled secret s' = Δ·s where Δ = (total!). Then the
// integer Lagrange numerator Λ_j = Δ · λ_j is an exact integer with
// |Λ_j| ≤ Δ for any subset of size t. The combine step yields
//
// combine = Δ · m + Σ_j Λ_j · e_j (mod q)
//
// and we divide by Δ (mod q) at the very end. The noise budget on the
// smudging σ is then Δ · σ · √t · 6 ≤ Q/16, which is achievable
// for the standard luxfi/fhe parameter sets up to (t, n) = (11, 21).
//
// We expose Δ as deltaFactorial; it's tiny (uint64-fits) for n ≤ 20 and
// requires big.Int once n > 20. We use big.Int unconditionally for
// uniformity.
//
// References:
// - Bendlin, Damgård. "Threshold Decryption and Zero-Knowledge Proofs
// for Lattice-Based Cryptosystems." TCC 2010, §4.2.
// - Asharov, Jain, López-Alt, Tromer, Vaikuntanathan, Wichs.
// "Multiparty Computation … via Threshold FHE." EUROCRYPT 2012, §4.
// deltaFactorial returns Δ = total! as a big.Int.
func deltaFactorial(total int) *big.Int {
d := big.NewInt(1)
for k := int64(2); k <= int64(total); k++ {
d.Mul(d, big.NewInt(k))
}
return d
}
// SmudgingSigma computes the smudging-noise σ for the given LWE
// parameters, threshold, and total committee size. The choice satisfies
// the Bendlin-Damgård noise bound
//
// max_i |Λ_i| · σ · √t · 6 ≤ Q/16,
//
// where Λ_i is the integer Bendlin-Damgård Lagrange numerator (which is
// bounded by Δ · C(n-1, t-1) for the worst-case subset, and 6 covers the
// truncation bound of the discrete Gaussian (we sample bounded by 6σ
// matches lattice/ring conventions).
//
// The combined-noise worst case is when partial indices cluster at one
// end of {1..n}; the Lagrange numerator at the far point scales as
// Δ · (n-1 choose t-1) for that subset. We use that tight bound here.
//
// For PN10QP27 (Q ≈ 2^27) at (t, n) = (2, 3): Δ · C(2,1) = 6 · 2 = 12,
// so σ ≤ Q/(16 · 6 · √2 · 12) ≈ 82400 — very comfortable.
//
// For (t, n) = (5, 9): Δ · C(8,4) = 362880 · 70 = 2.5e7; σ ≤ Q/(16 · 6 ·
// √5 · 2.5e7) ≈ 0.025 — well below 1, so PN10QP27 *cannot* support 5-of-
// 9 securely. The caller must escalate to PN11QP54 (Q ≈ 2^54) or
// PN12QP109. This function returns the upper-bound σ regardless;
// callers who get σ < 1 should pick a wider parameter set.
//
// Returns σ_smudge for use in PartialDecryptLWE. Passing a larger σ
// breaks the rounding margin, a smaller one weakens the masking.
func SmudgingSigma(params rlwe.Parameters, threshold, total int) float64 {
q := float64(params.Q()[0])
worst := worstLagrangeBoundFloat(threshold, total)
margin := q / 16.0
bound := 6.0 * sqrtApprox(float64(threshold)) * worst
if bound <= 0 {
return 1
}
sigma := margin / bound
if sigma < 1 {
// Floor at 1 so callers still get *some* masking; whether this
// is cryptographically sufficient is a parameter-set choice
// (see comment above).
sigma = 1
}
return sigma
}
// worstLagrangeBoundFloat returns Δ · max_{S ⊂ [n], |S|=t} max_{i ∈ S}
// |∏_{j ∈ S, j≠i} x_j / ∏_{j ∈ S, j≠i}(x_i - x_j)|, lifted to float64.
//
// The maximum over all t-subsets is achieved when the subset clusters at
// one end of [1..total]. We bound by Δ · C(total-1, threshold-1) which
// is sharp for the cluster-at-one-end case.
func worstLagrangeBoundFloat(threshold, total int) float64 {
if threshold <= 0 || total < threshold {
return 0
}
delta := deltaFactorial(total)
binom := new(big.Int).Binomial(int64(total-1), int64(threshold-1))
w := new(big.Int).Mul(delta, binom)
f, _ := new(big.Float).SetInt(w).Float64()
return f
}
// sqrtApprox returns a cheap sqrt suitable for parameter-set sizing.
// Newton's method, six iterations, seeded with x/2 — exact to >2 decimals
// for the N values we care about (256..2048).
func sqrtApprox(x float64) float64 {
if x <= 0 {
return 0
}
z := x / 2
for i := 0; i < 6; i++ {
z = (z + x/z) / 2
}
return z
}
// LWEShare carries one party's Shamir share of the LWE secret key under
// the Bendlin-Damgård denominator-clearing scheme.
//
// The share is itself a polynomial s_j ∈ R_q in *coefficient* domain
// (non-NTT, non-Montgomery). The i-th coefficient s_j[i] equals f_i(j)
// where f_i is the secret-sharing polynomial for the i-th coefficient of
// Δ·s (NOT s) and Δ = total!. Index is the Shamir x-coordinate (1-based).
//
// Combine recovers Δ·s exactly via *integer* Lagrange coefficients (no
// modular inverse on the noise path), then divides by Δ mod q to recover
// s. See partial_decrypt.go header comment for the full noise budget.
//
// Domain choice: we store in coefficient form because (a) it serialises
// compactly (just N·8 bytes for level=0) and (b) partial decrypt needs to
// re-NTT and re-MForm anyway with the local PRNG state. Crucially, the
// share lives in the *standard* representation, NOT the NTT+Montgomery
// representation of the master secret — a stolen NTT-Montgomery share
// would leak the same information; that's not a defect of either form.
type LWEShare struct {
// Index is the Shamir x-coordinate, 1-based.
Index int
// Coeffs is the polynomial s_j ∈ Z_q[X]/(X^N+1) in coefficient form,
// representing one party's share of Δ·s. Length is exactly N.
Coeffs []uint64
// Q is the LWE modulus this share is bound to. Recorded so combine
// can detect cross-parameter mixing.
Q uint64
// Total is the committee size used at sharing time. Combine needs
// this to compute Δ = Total! and divide it out at the end. Carried
// per-share so a stale share is detected at combine time.
Total int
}
// LWEPartialDecryption is one party's partial decryption d_j of an LWE
// ciphertext under their LWEShare. Stored in NTT domain so the combine
// step can sum directly without re-NTT.
type LWEPartialDecryption struct {
// Index is the Shamir x-coordinate of the contributing share.
Index int
// Value is the polynomial d_j = c_1·s_j + e_j in NTT domain.
// Length is exactly N at level 0. s_j here is a share of Δ·s,
// matching the LWEShare convention.
Value []uint64
// Q is the LWE modulus.
Q uint64
// Total is the committee size at sharing time, copied from the
// contributing LWEShare. Combine uses it to derive Δ.
Total int
}
// ShareLWESecretKey Shamir-splits the LWE secret key coefficient-by-
// coefficient over Z_q where q is the LWE modulus.
//
// Pipeline:
//
// 1. Extract the secret-key polynomial from NTT+Montgomery form back to
// standard coefficient form. (skLWE.Value.Q is stored NTT+Montgomery
// per rlwe.KeyGenerator.genSecretKeyFromSampler.)
// 2. For each coefficient s[i], pick a random degree-(t-1) polynomial f_i
// with f_i(0) = s[i], evaluate at x = 1, ..., total.
// 3. Bundle the j-th evaluation of every coefficient into the j-th share.
//
// The master secret is NEVER materialised outside step 1's local buffer;
// after sharing, the caller is expected to zero it. (Callers should also
// zero the rlwe.SecretKey itself if they only need shares.)
//
// Parameters validation: threshold must be in [1, total]; an honest-
// majority deployment picks t = floor(total/2)+1. For total > q-1 the
// Shamir x-coordinates would collide modulo q; we explicitly reject this
// to avoid silent share corruption.
func ShareLWESecretKey(skLWE *rlwe.SecretKey, params rlwe.Parameters, threshold, total int) ([]LWEShare, error) {
if threshold < 1 || threshold > total {
return nil, fmt.Errorf("threshold/lwe: bad threshold %d for total %d", threshold, total)
}
q := params.Q()[0]
if uint64(total) >= q {
return nil, fmt.Errorf("threshold/lwe: total %d exceeds LWE modulus %d", total, q)
}
ringQ := params.RingQ()
N := ringQ.N()
// 1) Extract the secret key to standard coefficient form.
// skLWE.Value.Q is NTT + Montgomery; mirror genSecretKeyFromSampler
// in reverse: first IMForm then INTT.
skStd := ringQ.NewPoly()
ringQ.IMForm(skLWE.Value.Q, skStd)
ringQ.INTT(skStd, skStd)
// skStd.Coeffs[0] now holds the standard coefficients in [0, q).
// 2) Share s itself (not Δ·s). The Bendlin-Damgård trick is that
// the *integer* Lagrange numerators Λ_j = Δ · λ_j satisfy
// Σ_j Λ_j · f(j) = Δ · f(0) by linearity; combine therefore
// yields Δ · s without any pre-scaling of the secret. The
// smudging noise is the only term that picks up a Λ-factor in
// magnitude, and we calibrate σ accordingly (see SmudgingSigma).
qBig := new(big.Int).SetUint64(q)
// 3) For each of the N coefficients, generate a random degree-(t-1)
// polynomial whose constant term is s[i] mod q, then evaluate
// at x = 1..total.
shares := make([]LWEShare, total)
for j := 0; j < total; j++ {
shares[j] = LWEShare{
Index: j + 1,
Coeffs: make([]uint64, N),
Q: q,
Total: total,
}
}
for i := 0; i < N; i++ {
coeffs := make([]*big.Int, threshold)
coeffs[0] = new(big.Int).SetUint64(skStd.Coeffs[0][i])
for k := 1; k < threshold; k++ {
r, err := rand.Int(rand.Reader, qBig)
if err != nil {
return nil, fmt.Errorf("threshold/lwe: random coeff: %w", err)
}
coeffs[k] = r
}
for j := 0; j < total; j++ {
x := new(big.Int).SetInt64(int64(j + 1))
shares[j].Coeffs[i] = evalPolyModQ(coeffs, x, qBig)
}
}
// Zero the local standard-form copy of the secret. The caller still
// owns skLWE — they may zero or keep it as the dealer's choice.
skStd.Zero()
return shares, nil
}
// evalPolyModQ evaluates coeffs[0] + coeffs[1]·x + ... + coeffs[t-1]·x^(t-1)
// modulo q using Horner's method, returning a uint64 in [0, q).
func evalPolyModQ(coeffs []*big.Int, x, q *big.Int) uint64 {
acc := new(big.Int).Set(coeffs[len(coeffs)-1])
for k := len(coeffs) - 2; k >= 0; k-- {
acc.Mul(acc, x)
acc.Add(acc, coeffs[k])
acc.Mod(acc, q)
}
if acc.Sign() < 0 {
acc.Add(acc, q)
}
return acc.Uint64()
}
// PartialDecryptLWE produces party j's partial decryption d_j of the LWE
// ciphertext ct under their LWEShare. The output is in NTT domain at
// level 0.
//
// d_j = c_1 · s_j + e_j (mod q, NTT)
//
// where c_1 is ct.Value[1] (already in NTT per rlwe convention), s_j is
// the share polynomial NTT'd into the LWE ring, and e_j is sampled from
// a discrete Gaussian with the supplied σ. The share polynomial is
// reduced into the LWE ring on the fly; the caller must not pre-NTT it.
//
// IMPORTANT: every call to PartialDecryptLWE on the same ciphertext with
// the same share *must* produce a new e_j. Reusing e_j across calls
// completely breaks the noise-flooding masking. The caller-supplied prng
// is responsible for this freshness; we wire a fresh PRNG per call to
// make the contract explicit.
func PartialDecryptLWE(
share *LWEShare,
ct *rlwe.Ciphertext,
params rlwe.Parameters,
sigma float64,
prng sampling.PRNG,
) (*LWEPartialDecryption, error) {
if share == nil {
return nil, fmt.Errorf("threshold/lwe: nil share")
}
q := params.Q()[0]
if share.Q != q {
return nil, fmt.Errorf("threshold/lwe: share q=%d does not match params q=%d", share.Q, q)
}
ringQ := params.RingQ().AtLevel(0)
if got := len(share.Coeffs); got != ringQ.N() {
return nil, fmt.Errorf("threshold/lwe: share length %d != ring N %d", got, ringQ.N())
}
// 1) Lift the share into the lattice ring representation. We start in
// coefficient form, NTT, then MForm so the Montgomery-domain
// multiplication MulCoeffsMontgomery composes correctly.
sPoly := ringQ.NewPoly()
copy(sPoly.Coeffs[0], share.Coeffs)
ringQ.NTT(sPoly, sPoly)
ringQ.MForm(sPoly, sPoly)
// 2) Compute c_1 · s_j in NTT/Montgomery domain.
// rlwe stores ciphertexts with Value[1] in NTT form already.
c1 := ct.Value[1]
if !ct.IsNTT {
// Defensive: NTT into a buffer rather than mutating the caller's
// ciphertext. The standard fhe.Encryptor produces NTT ciphertexts
// (see encryptor.go addPtToCt + lwe encoding), but we handle both.
buf := ringQ.NewPoly()
ringQ.NTT(c1, buf)
c1 = buf
}
dPoly := ringQ.NewPoly()
ringQ.MulCoeffsMontgomery(c1, sPoly, dPoly)
// 3) Sample smudging noise e_j ∈ R_q with discrete-Gaussian σ.
// We sample in standard form, NTT, then add to d_j. Bound is set
// to 6σ (truncated Gaussian — matches lattice/ring conventions).
bound := 6.0 * sigma
if bound > float64(q)/2 {
bound = float64(q) / 2
}
xeSampler, err := ring.NewSampler(prng, ringQ, ring.DiscreteGaussian{
Sigma: sigma,
Bound: bound,
}, false)
if err != nil {
return nil, fmt.Errorf("threshold/lwe: noise sampler: %w", err)
}
ePoly := ringQ.NewPoly()
xeSampler.AtLevel(0).Read(ePoly)
ringQ.NTT(ePoly, ePoly)
ringQ.Add(dPoly, ePoly, dPoly)
// Zero the share's lattice-ring lift; the persistent share is in
// coefficient form (share.Coeffs) and was untouched.
sPoly.Zero()
ePoly.Zero()
out := &LWEPartialDecryption{
Index: share.Index,
Value: make([]uint64, ringQ.N()),
Q: q,
Total: share.Total,
}
copy(out.Value, dPoly.Coeffs[0])
return out, nil
}
// CombineLWE Lagrange-interpolates ≥ t partial decryptions and finishes
// the recovery, returning the recovered plaintext polynomial in NTT.
//
// Under Bendlin-Damgård denominator clearing we recover Δ·m where Δ =
// total!:
//
// Δ · pt = Δ · c_0 + Σ_j Λ_j · d_j (NTT)
//
// where Λ_j = Δ · λ_j ∈ are *integer* Lagrange numerators, and the
// noise stays bounded by |Σ_j Λ_j · e_j| ≤ Δ · √t · 6σ ≤ Q/16. After
// summing in Z_q we multiply by Δ^{-1} mod q to peel the scaling off
// and return the canonical plaintext polynomial.
//
// Returns the plaintext polynomial in NTT form. Apply INTT then read
// coefficient 0 for bit ciphertexts to recover the bit.
//
// Errors out if any partial's Q or Total disagrees with the supplied
// params, if indices repeat, or if fewer than 1 partial is supplied.
// The threshold itself is enforced by the caller — combine does not
// know t. Re-using partials beyond their Total field's value is rejected
// to catch cross-keygen mixing.
func CombineLWE(
ct *rlwe.Ciphertext,
partials []*LWEPartialDecryption,
params rlwe.Parameters,
) (*rlwe.Plaintext, error) {
if len(partials) < 1 {
return nil, fmt.Errorf("threshold/lwe: no partials supplied")
}
q := params.Q()[0]
total := partials[0].Total
for _, p := range partials {
if p == nil {
return nil, fmt.Errorf("threshold/lwe: nil partial")
}
if p.Q != q {
return nil, fmt.Errorf("threshold/lwe: partial q=%d != params q=%d", p.Q, q)
}
if p.Total != total {
return nil, fmt.Errorf("threshold/lwe: partial total=%d differs from %d", p.Total, total)
}
}
seen := make(map[int]struct{}, len(partials))
for _, p := range partials {
if _, dup := seen[p.Index]; dup {
return nil, fmt.Errorf("threshold/lwe: duplicate partial index %d", p.Index)
}
seen[p.Index] = struct{}{}
}
ringQ := params.RingQ().AtLevel(0)
qBig := new(big.Int).SetUint64(q)
delta := deltaFactorial(total)
deltaMod := new(big.Int).Mod(delta, qBig)
// Accumulator starts at Δ · c_0 in NTT form (since the right-hand
// side of the Bendlin-Damgård identity is Δ · c_0 + Σ_j Λ_j d_j).
accum := ringQ.NewPoly()
c0 := ct.Value[0]
if ct.IsNTT {
accum.Coeffs[0] = append([]uint64(nil), c0.Coeffs[0]...)
} else {
// Defensive NTT into a buffer if the ciphertext is non-NTT.
ringQ.NTT(c0, accum)
}
ringQ.MulScalar(accum, deltaMod.Uint64(), accum)
// For each partial: Λ_j (integer, then mod q for ring scalar mul),
// then accum += Λ_j · d_j in NTT.
scratch := ringQ.NewPoly()
for i, pi := range partials {
lambdaInt := lagrangeIntegerNumeratorAtZero(partials, i, delta)
// Reduce mod q for the lattice ring-scalar multiply.
lambdaMod := new(big.Int).Mod(lambdaInt, qBig)
if lambdaMod.Sign() < 0 {
lambdaMod.Add(lambdaMod, qBig)
}
// Lift partial into a poly, then scale by Λ_j mod q.
copy(scratch.Coeffs[0], pi.Value)
ringQ.MulScalar(scratch, lambdaMod.Uint64(), scratch)
ringQ.Add(accum, scratch, accum)
}
scratch.Zero()
// Divide out Δ to recover m: multiply accum by Δ^{-1} mod q.
deltaInv := new(big.Int).ModInverse(deltaMod, qBig)
if deltaInv == nil {
return nil, fmt.Errorf("threshold/lwe: Δ has no inverse mod q (total %d, q %d)", total, q)
}
ringQ.MulScalar(accum, deltaInv.Uint64(), accum)
pt := rlwe.NewPlaintext(params, 0)
pt.Value.Coeffs[0] = accum.Coeffs[0]
pt.IsNTT = true
return pt, nil
}
// lagrangeIntegerNumeratorAtZero returns Λ_i = Δ · λ_i evaluated at 0
// for the supplied subset of partials, where Δ is total! (assumed to
// equal delta). Λ_i is an *exact integer* by Bendlin-Damgård §4.2;
// |Λ_i| ≤ Δ for any subset of size t ≤ total.
//
// λ_i = ∏_{j≠i} (-x_j) / (x_i - x_j), and Λ_i = Δ · λ_i. We compute
// the integer numerator N_i = ∏(-x_j) and denominator D_i = ∏(x_i - x_j),
// then return Δ · N_i / D_i as a big.Int (the division is exact when
// Δ = total!).
func lagrangeIntegerNumeratorAtZero(partials []*LWEPartialDecryption, i int, delta *big.Int) *big.Int {
xi := new(big.Int).SetInt64(int64(partials[i].Index))
num := big.NewInt(1)
den := big.NewInt(1)
for j, pj := range partials {
if j == i {
continue
}
xj := new(big.Int).SetInt64(int64(pj.Index))
// num *= (-xj) — full integer product, no mod.
neg := new(big.Int).Neg(xj)
num.Mul(num, neg)
// den *= (xi - xj)
diff := new(big.Int).Sub(xi, xj)
den.Mul(den, diff)
}
// Λ = Δ · num / den. The division is exact because den divides Δ:
// den = ∏_{j≠i}(x_i - x_j) divides ∏_{k=1..total}(x_i - k) which
// divides total! · (a unit). The standard B-D argument.
t := new(big.Int).Mul(delta, num)
q, r := new(big.Int).QuoRem(t, den, new(big.Int))
if r.Sign() != 0 {
// Should never happen if delta = total! and indices ⊂ {1..total}.
// Panic loudly — wrong delta would silently break security.
panic(fmt.Sprintf("threshold/lwe: non-exact integer Lagrange numerator (delta=%s, t=%s, den=%s, r=%s)", delta, t, den, r))
}
return q
}
// DecryptBitFromPlaintext decodes a single bit from the recovered
// plaintext polynomial, applying luxfi/fhe's encoding scheme: true ↦ Q/8,
// false ↦ 7Q/8. After INTT, coefficient 0 lies near +Q/8 or -Q/8; we
// return true iff it lies in [0, Q/2). Mirrors fhe.Decryptor.Decrypt.
func DecryptBitFromPlaintext(pt *rlwe.Plaintext, params rlwe.Parameters) bool {
ringQ := params.RingQ().AtLevel(0)
if pt.IsNTT {
// INTT into a buffer so we don't mutate the caller's plaintext.
out := ringQ.NewPoly()
ringQ.INTT(pt.Value, out)
c := out.Coeffs[0][0]
out.Zero()
return c < params.Q()[0]>>1
}
c := pt.Value.Coeffs[0][0]
return c < params.Q()[0]>>1
}
// ShareLWESecretKeyFHE is a thin convenience over ShareLWESecretKey that
// works directly against fhe.Parameters / fhe.SecretKey. Use this from
// out-of-tree callers (notably luxfi/threshold/protocols/tfhe) who already
// have fhe types in hand.
func ShareLWESecretKeyFHE(sk *fhe.SecretKey, params fhe.Parameters, threshold, total int) ([]LWEShare, error) {
return ShareLWESecretKey(sk.SKLWE, params.ParamsLWE(), threshold, total)
}
// PartialDecryptFHE is a thin convenience that operates against the
// fhe.Ciphertext + fhe.Parameters wrapper. Returns a per-bit partial
// decryption sized for the supplied threshold (used only to pick σ).
//
// The σ choice here uses (threshold, share.Total) per Bendlin-Damgård.
// PRNG defaults to a fresh sampling.PRNG when nil — production callers
// pass a process-managed PRNG so smudging noise is logged for audit.
func PartialDecryptFHE(
share *LWEShare,
ct *fhe.Ciphertext,
params fhe.Parameters,
threshold int,
prng sampling.PRNG,
) (*LWEPartialDecryption, error) {
if prng == nil {
var err error
prng, err = sampling.NewPRNG()
if err != nil {
return nil, fmt.Errorf("threshold/lwe: default prng: %w", err)
}
}
sigma := SmudgingSigma(params.ParamsLWE(), threshold, share.Total)
return PartialDecryptLWE(share, ct.Ciphertext, params.ParamsLWE(), sigma, prng)
}
// CombineFHE Lagrange-combines a set of LWEPartialDecryptions and
// decodes a single bit. Convenience over CombineLWE + DecryptBit.
func CombineFHE(
ct *fhe.Ciphertext,
partials []*LWEPartialDecryption,
params fhe.Parameters,
) (bool, error) {
pt, err := CombineLWE(ct.Ciphertext, partials, params.ParamsLWE())
if err != nil {
return false, err
}
return DecryptBitFromPlaintext(pt, params.ParamsLWE()), nil
}