From 695cde354564bdb5f818b870512a1e0e0c06e154 Mon Sep 17 00:00:00 2001 From: Hanzo AI Date: Fri, 29 May 2026 11:52:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(threshold):=20real=20Bendlin-Damg=C3=A5rd?= =?UTF-8?q?=20M-of-N=20partial=20decrypt=20for=20LWE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bitwise_integers.go | 19 + fhe.go | 14 + pkg/threshold/partial_decrypt.go | 629 ++++++++++++++++++++++++++ pkg/threshold/partial_decrypt_test.go | 296 ++++++++++++ 4 files changed, 958 insertions(+) create mode 100644 pkg/threshold/partial_decrypt.go create mode 100644 pkg/threshold/partial_decrypt_test.go diff --git a/bitwise_integers.go b/bitwise_integers.go index 13d8474..7dc8c74 100644 --- a/bitwise_integers.go +++ b/bitwise_integers.go @@ -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{ diff --git a/fhe.go b/fhe.go index 47ac379..1e31376 100644 --- a/fhe.go +++ b/fhe.go @@ -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] diff --git a/pkg/threshold/partial_decrypt.go b/pkg/threshold/partial_decrypt.go new file mode 100644 index 0000000..cf058e9 --- /dev/null +++ b/pkg/threshold/partial_decrypt.go @@ -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 +} diff --git a/pkg/threshold/partial_decrypt_test.go b/pkg/threshold/partial_decrypt_test.go new file mode 100644 index 0000000..64e58e5 --- /dev/null +++ b/pkg/threshold/partial_decrypt_test.go @@ -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") + } +}