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.
This commit is contained in:
Hanzo Dev
2026-05-29 12:07:05 -07:00
committed by GitHub
parent 80dba8cb8b
commit 378b74758c
4 changed files with 958 additions and 0 deletions
+19
View File
@@ -33,6 +33,25 @@ func (bc *BitCiphertext) NumBits() int {
return bc.numBits
}
// Bits returns the per-bit ciphertexts. The returned slice aliases the
// internal storage — callers must not mutate it. Exposed so threshold
// decryption can iterate per-bit without round-tripping through
// MarshalBinary; threshold-FHE operates one *Ciphertext at a time.
func (bc *BitCiphertext) Bits() []*Ciphertext {
return bc.bits
}
// NewBitCiphertextFromBits constructs a BitCiphertext from a slice of
// per-bit ciphertexts. Used by threshold decryption to rebuild a
// BitCiphertext after operating on individual bits.
func NewBitCiphertextFromBits(bits []*Ciphertext, t FheUintType) *BitCiphertext {
return &BitCiphertext{
bits: bits,
numBits: len(bits),
fheType: t,
}
}
// WrapBoolCiphertext wraps a single bit ciphertext into a BitCiphertext of type FheBool
func WrapBoolCiphertext(ct *Ciphertext) *BitCiphertext {
return &BitCiphertext{
+14
View File
@@ -138,6 +138,20 @@ func (p Parameters) NBR() int {
return p.paramsBR.N()
}
// ParamsLWE returns the underlying rlwe.Parameters used for LWE bit ciphertexts.
// Exposed so out-of-tree consumers (notably pkg/threshold) can drive lattice
// primitives directly against the same parameter set used by Encryptor /
// Decryptor — no duplicate parameter construction, no ad-hoc Q recomputation.
func (p Parameters) ParamsLWE() rlwe.Parameters {
return p.paramsLWE
}
// ParamsBR returns the underlying rlwe.Parameters used for blind-rotation /
// bootstrapping. Exposed for the same reason as ParamsLWE.
func (p Parameters) ParamsBR() rlwe.Parameters {
return p.paramsBR
}
// QLWE returns the LWE modulus
func (p Parameters) QLWE() uint64 {
return p.paramsLWE.Q()[0]
+629
View File
@@ -0,0 +1,629 @@
// 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
}
+296
View File
@@ -0,0 +1,296 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package threshold
import (
"testing"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/utils/sampling"
)
// TestPartialDecrypt_RoundTrip_2of3 verifies that the canonical 2-of-3
// flow recovers the plaintext bit. This is the smoke test: real shares,
// real partials, real Lagrange combine, real bit decoding.
func TestPartialDecrypt_RoundTrip_2of3(t *testing.T) {
for _, value := range []bool{false, true} {
runRoundTrip(t, fhe.PN10QP27, 2, 3, value)
}
}
// TestPartialDecrypt_RoundTrip_5of9 covers an asymmetric setup. Uses
// PN11QP54 because PN10QP27's modulus is too small to support t=5 with
// secure noise flooding (Λ_max · σ · √t · 6 > Q/16 at PN10).
func TestPartialDecrypt_RoundTrip_5of9(t *testing.T) {
for _, value := range []bool{false, true} {
runRoundTrip(t, fhe.PN11QP54, 5, 9, value)
}
}
// TestPartialDecrypt_RoundTrip_11of21 covers a larger committee — the
// canonical Bendlin-Damgård benchmark setting. PN11QP54 (Q ≈ 2^54)
// suffices for t=11. Skipped under -short (keygen is slow).
func TestPartialDecrypt_RoundTrip_11of21(t *testing.T) {
if testing.Short() {
t.Skip("skipping 11-of-21 round-trip under -short")
}
for _, value := range []bool{false, true} {
runRoundTrip(t, fhe.PN11QP54, 11, 21, value)
}
}
func runRoundTrip(t *testing.T, lit fhe.ParametersLiteral, threshold, total int, value bool) {
t.Helper()
params, err := fhe.NewParametersFromLiteral(lit)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
// Distribute shares.
shares, err := ShareLWESecretKeyFHE(sk, params, threshold, total)
if err != nil {
t.Fatalf("share: %v", err)
}
if got, want := len(shares), total; got != want {
t.Fatalf("share count: got %d want %d", got, want)
}
// Encrypt with the master key (collective public key in the
// production flow; either yields the same threshold-decryptable
// ciphertext).
enc := fhe.NewEncryptor(params, sk)
ct := enc.Encrypt(value)
// Each party in a chosen subset of size `threshold` produces a
// partial. We choose the *last* t parties to avoid lucky alignment
// with the first-t shares carrying obviously-small Lagrange weights.
subset := shares[total-threshold:]
partials := make([]*LWEPartialDecryption, threshold)
for i, share := range subset {
share := share
prng, err := sampling.NewPRNG()
if err != nil {
t.Fatalf("prng: %v", err)
}
p, err := PartialDecryptFHE(&share, ct, params, threshold, prng)
if err != nil {
t.Fatalf("partial[%d]: %v", i, err)
}
if p.Index != share.Index {
t.Fatalf("partial index mismatch: got %d want %d", p.Index, share.Index)
}
partials[i] = p
}
got, err := CombineFHE(ct, partials, params)
if err != nil {
t.Fatalf("combine: %v", err)
}
if got != value {
t.Fatalf("round trip: got %v want %v (subset of %d, threshold %d)", got, value, total, threshold)
}
}
// TestPartialDecrypt_BelowThresholdFails_2of3 confirms that 1 of 3
// partials cannot recover the plaintext (negative correctness test).
//
// Note: this is a *correctness* property — a single partial is exactly
// c_1·s_j + e_j, and adding it to c_0 yields a polynomial whose
// constant term is c_0 + c_1·s_j + e_j, far from the m + e of the
// honest decryption. We test that the decoded bit is wrong w.h.p.;
// security is a separate semantic statement covered by the simulation
// argument (not testable from black-box).
func TestPartialDecrypt_BelowThresholdFails_2of3(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
shares, err := ShareLWESecretKeyFHE(sk, params, 2, 3)
if err != nil {
t.Fatalf("share: %v", err)
}
enc := fhe.NewEncryptor(params, sk)
ct := enc.Encrypt(true)
prng, _ := sampling.NewPRNG()
share := shares[0]
p, err := PartialDecryptFHE(&share, ct, params, 1, prng)
if err != nil {
t.Fatalf("partial: %v", err)
}
// Try to "combine" with 1 partial — Lagrange basis for {x_0} at 0
// is identity, so this reduces to c_0 + (c_1·s_0 + e_0), which is
// not the master decryption. Statistically the decoded bit is wrong
// with probability ~1/2. We sample multiple plaintexts and check
// that disagreement is non-negligible.
disagreements := 0
trials := 32
for i := 0; i < trials; i++ {
value := i&1 == 0
ct2 := enc.Encrypt(value)
prng2, _ := sampling.NewPRNG()
p2, err := PartialDecryptFHE(&share, ct2, params, 1, prng2)
if err != nil {
t.Fatalf("partial[%d]: %v", i, err)
}
got, err := CombineFHE(ct2, []*LWEPartialDecryption{p2}, params)
if err != nil {
t.Fatalf("combine[%d]: %v", i, err)
}
if got != value {
disagreements++
}
}
_ = p
if disagreements == 0 {
t.Fatalf("below-threshold combine should disagree with plaintext; got 0/%d disagreements", trials)
}
}
// TestShareLWESecretKey_NoMasterKeyLeak_2of3 verifies that no party's
// stored share equals the master secret key polynomial.
func TestShareLWESecretKey_NoMasterKeyLeak_2of3(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
shares, err := ShareLWESecretKeyFHE(sk, params, 2, 3)
if err != nil {
t.Fatalf("share: %v", err)
}
// Lift sk to standard form to compare against shares (the share
// representation).
ringQ := params.ParamsLWE().RingQ()
skStd := ringQ.NewPoly()
ringQ.IMForm(sk.SKLWE.Value.Q, skStd)
ringQ.INTT(skStd, skStd)
for _, share := range shares {
for i := 0; i < ringQ.N(); i++ {
if share.Coeffs[i] == skStd.Coeffs[0][i] && share.Coeffs[i] != 0 {
// A single coordinate matching is statistically possible.
// We only flag if the entire vector matches.
}
}
match := true
for i := 0; i < ringQ.N(); i++ {
if share.Coeffs[i] != skStd.Coeffs[0][i] {
match = false
break
}
}
if match {
t.Fatalf("share at index %d equals master secret coefficients", share.Index)
}
}
}
// TestCombineLWE_DuplicateIndexRejected verifies the dedup guard.
func TestCombineLWE_DuplicateIndexRejected(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
shares, err := ShareLWESecretKeyFHE(sk, params, 2, 3)
if err != nil {
t.Fatalf("share: %v", err)
}
enc := fhe.NewEncryptor(params, sk)
ct := enc.Encrypt(true)
prng, _ := sampling.NewPRNG()
share := shares[0]
p, err := PartialDecryptFHE(&share, ct, params, 1, prng)
if err != nil {
t.Fatalf("partial: %v", err)
}
// Pass the same partial twice — should error before combining.
if _, err := CombineLWE(ct.Ciphertext, []*LWEPartialDecryption{p, p}, params.ParamsLWE()); err == nil {
t.Fatal("expected duplicate-index error")
}
}
// TestCombineLWE_MismatchedQRejected guards against cross-parameter mixing.
func TestCombineLWE_MismatchedQRejected(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
shares, err := ShareLWESecretKeyFHE(sk, params, 2, 3)
if err != nil {
t.Fatalf("share: %v", err)
}
enc := fhe.NewEncryptor(params, sk)
ct := enc.Encrypt(true)
prng, _ := sampling.NewPRNG()
share := shares[0]
p, err := PartialDecryptFHE(&share, ct, params, 1, prng)
if err != nil {
t.Fatalf("partial: %v", err)
}
p.Q = p.Q ^ 1
if _, err := CombineLWE(ct.Ciphertext, []*LWEPartialDecryption{p}, params.ParamsLWE()); err == nil {
t.Fatal("expected q-mismatch error")
}
}
// TestPartialDecrypt_Deterministic_SameShares_SameOutput verifies that
// running the combine on the SAME partials yields the SAME plaintext.
// (PartialDecrypt is non-deterministic due to fresh noise; combine is
// deterministic in its inputs.)
func TestPartialDecrypt_Deterministic_SameShares_SameOutput(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
kgen := fhe.NewKeyGenerator(params)
sk, _ := kgen.GenKeyPair()
shares, err := ShareLWESecretKeyFHE(sk, params, 2, 3)
if err != nil {
t.Fatalf("share: %v", err)
}
enc := fhe.NewEncryptor(params, sk)
ct := enc.Encrypt(true)
prng1, _ := sampling.NewPRNG()
prng2, _ := sampling.NewPRNG()
share0 := shares[0]
share1 := shares[1]
p0, err := PartialDecryptFHE(&share0, ct, params, 2, prng1)
if err != nil {
t.Fatalf("partial 0: %v", err)
}
p1, err := PartialDecryptFHE(&share1, ct, params, 2, prng2)
if err != nil {
t.Fatalf("partial 1: %v", err)
}
// Run combine twice on the same partials.
got1, err := CombineFHE(ct, []*LWEPartialDecryption{p0, p1}, params)
if err != nil {
t.Fatalf("combine 1: %v", err)
}
got2, err := CombineFHE(ct, []*LWEPartialDecryption{p0, p1}, params)
if err != nil {
t.Fatalf("combine 2: %v", err)
}
if got1 != got2 {
t.Fatalf("combine non-deterministic in inputs: %v vs %v", got1, got2)
}
if !got1 {
t.Fatalf("plaintext recovery failed: got false want true")
}
}