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

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

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

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

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

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

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

297 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
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")
}
}