mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
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:
@@ -33,6 +33,25 @@ func (bc *BitCiphertext) NumBits() int {
|
|||||||
return bc.numBits
|
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
|
// WrapBoolCiphertext wraps a single bit ciphertext into a BitCiphertext of type FheBool
|
||||||
func WrapBoolCiphertext(ct *Ciphertext) *BitCiphertext {
|
func WrapBoolCiphertext(ct *Ciphertext) *BitCiphertext {
|
||||||
return &BitCiphertext{
|
return &BitCiphertext{
|
||||||
|
|||||||
@@ -138,6 +138,20 @@ func (p Parameters) NBR() int {
|
|||||||
return p.paramsBR.N()
|
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
|
// QLWE returns the LWE modulus
|
||||||
func (p Parameters) QLWE() uint64 {
|
func (p Parameters) QLWE() uint64 {
|
||||||
return p.paramsLWE.Q()[0]
|
return p.paramsLWE.Q()[0]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user