Threshold FHE moves to luxfi/threshold/protocols/tfhe

pkg/keycommit keeps what stays: a Shamir split, over the RFC 3526 Group-14
prime, of the SHA-256 commitment to a key. Its shares cannot decrypt, so
CommitKey and KeyCommitment replace GenerateSharedKey and KeyShare. fhed's
threshold mode follows the rename.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-17 13:26:44 -07:00
co-authored by Hanzo Dev
parent ae4d4b538b
commit 41d57d3c1e
9 changed files with 52 additions and 1847 deletions
+11 -11
View File
@@ -24,7 +24,7 @@ import (
"github.com/luxfi/fhe"
"github.com/luxfi/fhe/pkg/membership"
"github.com/luxfi/fhe/pkg/store"
"github.com/luxfi/fhe/pkg/threshold"
"github.com/luxfi/fhe/pkg/keycommit"
"github.com/luxfi/mdns"
)
@@ -187,7 +187,7 @@ type FHEDaemon struct {
// Threshold mode
thresholdMode bool
thresholdT int
keyShare *threshold.KeyShare
keyShare *keycommit.KeyCommitment
membership *membership.Manager
discovery *mdns.Discovery
@@ -486,7 +486,7 @@ func (d *FHEDaemon) loadOrGenerateThresholdKey() error {
if ksData, err := d.store.GetSecretKey(keyID); err == nil {
d.logger.Info("Loading existing key share from ZapDB")
ks, err := threshold.UnmarshalKeyShare(ksData)
ks, err := keycommit.UnmarshalCommitment(ksData)
if err != nil {
return fmt.Errorf("failed to unmarshal key share: %w", err)
}
@@ -521,7 +521,7 @@ func (d *FHEDaemon) loadOrGenerateThresholdKey() error {
d.logger.Warn("Generating single-party threshold keys for demo (NOT secure for production)")
sessionID := uuid.New().String()
result, err := threshold.GenerateSharedKey(d.params, d.thresholdT, d.thresholdT, sessionID)
result, err := keycommit.CommitKey(d.params, d.thresholdT, d.thresholdT, sessionID)
if err != nil {
return fmt.Errorf("failed to generate threshold keys: %w", err)
}
@@ -535,7 +535,7 @@ func (d *FHEDaemon) loadOrGenerateThresholdKey() error {
}
// Persist to ZapDB
ksData, err := threshold.MarshalKeyShare(d.keyShare)
ksData, err := keycommit.MarshalCommitment(d.keyShare)
if err != nil {
return fmt.Errorf("failed to marshal key share: %w", err)
}
@@ -1019,7 +1019,7 @@ func runKeygen(ctx context.Context, c *cli.Command) error {
fmt.Printf("Generating threshold keys (%d-of-%d)...\n", t, n)
sessionID := uuid.New().String()
result, err := threshold.GenerateSharedKey(params, t, n, sessionID)
result, err := keycommit.CommitKey(params, t, n, sessionID)
if err != nil {
return fmt.Errorf("failed to generate threshold keys: %w", err)
}
@@ -1031,7 +1031,7 @@ func runKeygen(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("failed to create party directory: %w", err)
}
ksData, err := threshold.MarshalKeyShare(share)
ksData, err := keycommit.MarshalCommitment(share)
if err != nil {
return fmt.Errorf("failed to marshal key share: %w", err)
}
@@ -1120,7 +1120,7 @@ func runReshare(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("failed to read input directory: %w", err)
}
var oldShares []*threshold.KeyShare
var oldShares []*keycommit.KeyCommitment
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -1135,7 +1135,7 @@ func runReshare(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("failed to read key share %s: %w", entry.Name(), err)
}
ks, err := threshold.UnmarshalKeyShare(ksData)
ks, err := keycommit.UnmarshalCommitment(ksData)
if err != nil {
return fmt.Errorf("failed to unmarshal key share %s: %w", entry.Name(), err)
}
@@ -1151,7 +1151,7 @@ func runReshare(ctx context.Context, c *cli.Command) error {
fmt.Printf("Resharing to %d-of-%d...\n", newT, newN)
newSessionID := uuid.New().String()
result, err := threshold.ReshareKey(oldShares, newT, newN, newSessionID)
result, err := keycommit.ReshareCommitment(oldShares, newT, newN, newSessionID)
if err != nil {
return fmt.Errorf("failed to reshare: %w", err)
}
@@ -1166,7 +1166,7 @@ func runReshare(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("failed to create party directory: %w", err)
}
ksData, err := threshold.MarshalKeyShare(share)
ksData, err := keycommit.MarshalCommitment(share)
if err != nil {
return fmt.Errorf("failed to marshal key share: %w", err)
}
@@ -1,7 +1,7 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package threshold
package keycommit
import (
"bytes"
@@ -13,8 +13,8 @@ import (
"github.com/luxfi/fhe"
)
// KeyShare represents a party's share of the FHE secret key
type KeyShare struct {
// KeyCommitment represents a party's share of the FHE secret key
type KeyCommitment struct {
PartyIndex int // 1-based party index
SessionID string // Keygen session identifier
@@ -29,32 +29,24 @@ type KeyShare struct {
Params fhe.Parameters
}
// KeyGenResult is the result of distributed key generation
type KeyGenResult struct {
// CommitResult is the result of distributed key generation
type CommitResult struct {
SessionID string
Threshold int
Total int
PublicKey *fhe.PublicKey
BootstrapKey *fhe.BootstrapKey
Params fhe.Parameters
Shares []*KeyShare // One per party
Shares []*KeyCommitment // One per party
}
// GenerateSharedKey is a TRUSTED-DEALER key-COMMITMENT facility, NOT the
// threshold-FHE key generator. It generates a keypair in one process and
// large-prime Shamir-splits the SHA-256 HASH of each secret-key component
// (hashToFieldElement) — i.e. it shares a COMMITMENT to the key, for verifiable-
// secret-sharing / dealer-equivocation auditing. The resulting KeyShare values
// are shares of a hash and CANNOT perform threshold decryption.
// CommitKey generates a keypair in one process and Shamir-splits the SHA-256
// hash of each secret-key component. The resulting shares are shares of a hash
// and cannot perform threshold decryption.
//
// For the trustless threshold-FHE lane use DealerlessKeyGen (dealerless_dkg.go),
// which is dealerless (no party ever holds the FHE secret key) and produces
// decrypt-capable LWEShare values consumed by PartialDecryptLWE / CombineLWE. See
// the package doc for the canonical flow.
//
// Deprecated: not part of the threshold-decrypt path; retained only for the
// commitment-VSS use case.
func GenerateSharedKey(params fhe.Parameters, threshold, total int, sessionID string) (*KeyGenResult, error) {
// Deprecated: for threshold custody use
// github.com/luxfi/threshold/protocols/tfhe.
func CommitKey(params fhe.Parameters, threshold, total int, sessionID string) (*CommitResult, error) {
if threshold > total {
return nil, fmt.Errorf("threshold %d exceeds total %d", threshold, total)
}
@@ -90,9 +82,9 @@ func GenerateSharedKey(params fhe.Parameters, threshold, total int, sessionID st
}
// Create key shares for each party
shares := make([]*KeyShare, total)
shares := make([]*KeyCommitment, total)
for i := 0; i < total; i++ {
shares[i] = &KeyShare{
shares[i] = &KeyCommitment{
PartyIndex: i + 1,
SessionID: sessionID,
SKLWEShare: lweSharings.Shares[i],
@@ -104,7 +96,7 @@ func GenerateSharedKey(params fhe.Parameters, threshold, total int, sessionID st
}
}
return &KeyGenResult{
return &CommitResult{
SessionID: sessionID,
Threshold: threshold,
Total: total,
@@ -115,8 +107,8 @@ func GenerateSharedKey(params fhe.Parameters, threshold, total int, sessionID st
}, nil
}
// ReshareKey performs LSSS resharing to change threshold or add/remove parties
func ReshareKey(oldShares []*KeyShare, newThreshold, newTotal int, newSessionID string) (*KeyGenResult, error) {
// ReshareCommitment performs LSSS resharing to change threshold or add/remove parties
func ReshareCommitment(oldShares []*KeyCommitment, newThreshold, newTotal int, newSessionID string) (*CommitResult, error) {
if len(oldShares) == 0 {
return nil, fmt.Errorf("no shares provided")
}
@@ -146,9 +138,9 @@ func ReshareKey(oldShares []*KeyShare, newThreshold, newTotal int, newSessionID
}
// Create new key shares (public key remains the same)
newShares := make([]*KeyShare, newTotal)
newShares := make([]*KeyCommitment, newTotal)
for i := 0; i < newTotal; i++ {
newShares[i] = &KeyShare{
newShares[i] = &KeyCommitment{
PartyIndex: i + 1,
SessionID: newSessionID,
SKLWEShare: newLWEShares.Shares[i],
@@ -160,7 +152,7 @@ func ReshareKey(oldShares []*KeyShare, newThreshold, newTotal int, newSessionID
}
}
return &KeyGenResult{
return &CommitResult{
SessionID: newSessionID,
Threshold: newThreshold,
Total: newTotal,
@@ -172,7 +164,7 @@ func ReshareKey(oldShares []*KeyShare, newThreshold, newTotal int, newSessionID
}
// AddParty adds a new party by computing their share
func AddParty(existingShares []*KeyShare, newPartyIndex int, newSessionID string) (*KeyShare, error) {
func AddParty(existingShares []*KeyCommitment, newPartyIndex int, newSessionID string) (*KeyCommitment, error) {
if len(existingShares) == 0 {
return nil, fmt.Errorf("no shares provided")
}
@@ -201,7 +193,7 @@ func AddParty(existingShares []*KeyShare, newPartyIndex int, newSessionID string
return nil, fmt.Errorf("failed to add SKBR share: %w", err)
}
return &KeyShare{
return &KeyCommitment{
PartyIndex: newPartyIndex,
SessionID: newSessionID,
SKLWEShare: newLWEShare,
@@ -213,8 +205,8 @@ func AddParty(existingShares []*KeyShare, newPartyIndex int, newSessionID string
}, nil
}
// RefreshKeyShares refreshes all shares to provide proactive security
func RefreshKeyShares(shares []*KeyShare, newSessionID string) ([]*KeyShare, error) {
// RefreshCommitments refreshes all shares to provide proactive security
func RefreshCommitments(shares []*KeyCommitment, newSessionID string) ([]*KeyCommitment, error) {
if len(shares) == 0 {
return nil, fmt.Errorf("no shares provided")
}
@@ -251,9 +243,9 @@ func RefreshKeyShares(shares []*KeyShare, newSessionID string) ([]*KeyShare, err
}
// Create new key shares
newShares := make([]*KeyShare, total)
newShares := make([]*KeyCommitment, total)
for i := 0; i < total; i++ {
newShares[i] = &KeyShare{
newShares[i] = &KeyCommitment{
PartyIndex: shares[i].PartyIndex,
SessionID: newSessionID,
SKLWEShare: newLWESet.Shares[i],
@@ -285,8 +277,8 @@ func serializeRLWESecretKey(sk interface{}) ([]byte, error) {
return buf.Bytes(), nil
}
// MarshalKeyShare serializes a key share
func MarshalKeyShare(ks *KeyShare) ([]byte, error) {
// MarshalCommitment serializes a key share
func MarshalCommitment(ks *KeyCommitment) ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(ks); err != nil {
@@ -295,10 +287,10 @@ func MarshalKeyShare(ks *KeyShare) ([]byte, error) {
return buf.Bytes(), nil
}
// UnmarshalKeyShare deserializes a key share
func UnmarshalKeyShare(data []byte) (*KeyShare, error) {
// UnmarshalCommitment deserializes a key share
func UnmarshalCommitment(data []byte) (*KeyCommitment, error) {
dec := gob.NewDecoder(bytes.NewReader(data))
var ks KeyShare
var ks KeyCommitment
if err := dec.Decode(&ks); err != nil {
return nil, err
}
@@ -1,9 +1,15 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package threshold implements threshold FHE with LSSS (Linear Secret Sharing Scheme).
// LSSS enables dynamic resharing: adding/removing nodes without full key regeneration.
package threshold
// Package keycommit Shamir-splits, over the 2048-bit MODP prime below, a
// SHA-256 commitment to an FHE key's components, with Feldman commitments so a
// dealer cannot equivocate about what it committed to.
//
// Its shares cannot decrypt: the key is generated whole by whoever calls
// CommitKey, and what gets shared is a hash of it. Threshold custody of an FHE
// key is github.com/luxfi/threshold/protocols/tfhe.
package keycommit
import (
"crypto/rand"
@@ -1,7 +1,7 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package threshold
package keycommit
import (
"math/big"
-385
View File
@@ -1,385 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// Dealerless (trustless) threshold-FHE key generation.
//
// This file closes the one trustless gap in the threshold-FHE stack: the
// generation of the LWE encryption key. The partial-decrypt kernel
// (partial_decrypt.go) is already NO-RECONSTRUCT — the master secret s never
// appears outside the masked aggregate c_1·s. But ShareLWESecretKey assumes a
// TRUSTED DEALER: a single party that samples the whole secret key s and then
// Shamir-splits it. At that instant one process holds s — a single point of
// compromise that violates the trustless-by-default law (no trusted dealer +
// no-reconstruct, the same standard Corona/Pulsar/Magnetar are held to).
//
// DealerlessKeyGen removes the dealer. It is the standard GJKR/Pedersen
// distributed-key-generation structure, specialised to the RLWE/LWE secret and
// composed with the audited Lattigo multiparty collective-public-key protocol
// (CKG, github.com/luxfi/lattice/v7/multiparty):
//
// 1. CRS. All parties derive one common reference polynomial a ∈ R_q from a
// consensus-supplied seed (sampling.NewKeyedPRNG). Determinism: every
// validator derives the identical a.
//
// 2. Each party j independently samples its OWN secret contribution
// s_j ← χ (the library's secret distribution). The collective secret is
// s = Σ_j s_j. No party ever sees another party's s_j.
//
// 3. Collective public key (dealerless). Each party publishes the CKG share
// p_j = -a·s_j + e_j; the public aggregate is p = Σ_j p_j = -a·s + e, and
// pk = (p, a) encrypts to the collective secret s. (Audited Lattigo CKG.)
//
// 4. t-of-n shares (dealerless). Each party j Shamir-splits its OWN s_j
// coefficient-by-coefficient over Z_q: it sends party k the sub-share
// σ_{j→k}[i] = f_{j,i}(k) where f_{j,i} is a fresh degree-(t-1) polynomial
// with f_{j,i}(0) = s_j[i]. Each recipient k sums the sub-shares it
// receives: σ_k[i] = Σ_j σ_{j→k}[i] = (Σ_j f_{j,i})(k). Because
// (Σ_j f_{j,i})(0) = Σ_j s_j[i] = s[i], party k holds a degree-(t-1)
// Shamir share of the collective secret s — and no party, nor this
// package, ever forms s.
//
// The resulting per-party LWEShare is bit-for-bit the same type the proven
// PartialDecryptLWE / CombineLWE kernel already consumes, so threshold
// decryption is unchanged: encrypt under the collective pk, ≥ t parties each
// PartialDecryptLWE with their share, CombineLWE recovers the plaintext. The
// secret s exists only as the (never-materialised) sum of the parties'
// contributions and only ever acts through the masked aggregate.
//
// DECOMPLECTING NOTE. This is the F-Chain (confidential-decrypt) lane only.
// It generates the LWE ENCRYPTION key and its t-of-n shares — everything the
// confidential encrypt → store → threshold-decrypt flow needs. It deliberately
// does NOT generate the FHEW blind-rotation / bootstrap key (needed for
// homomorphic COMPUTE on committee ciphertexts); dealerless bootstrap-key
// generation is a separate multiparty-FHEW protocol and is documented as the
// residual in this package's README, not faked here.
//
// References:
// - Gennaro, Jarecki, Krawczyk, Rabin. "Secure Distributed Key Generation
// for Discrete-Log Based Cryptosystems." EUROCRYPT 1999. (Dealerless DKG.)
// - Mouchet, Troncoso-Pastoriza, Bossuat, Hubaux. "Multiparty Homomorphic
// Encryption from Ring-Learning-with-Errors." PETS 2021. (Collective
// public key + threshold scheme; the lattice/multiparty implementation.)
// - Asharov, Jain, López-Alt, Tromer, Vaikuntanathan, Wichs. EUROCRYPT 2012.
// (Threshold-FHE decrypt; partial_decrypt.go.)
package threshold
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/multiparty"
"github.com/luxfi/lattice/v7/utils/sampling"
)
// PublicKeyShareMsg is a party's BROADCAST contribution to the collective
// public key: the CKG share -a·s_j + e_j. It is PUBLIC — it reveals nothing
// about s_j beyond what the final public key reveals about s — and carries no
// secret-key material.
type PublicKeyShareMsg struct {
From int
Share multiparty.PublicKeyGenShare
}
// SubShareMsg is a private point-to-point message: dealer `From` sends recipient
// `To` the coefficient-wise Shamir evaluation, at x=To, of `From`'s OWN secret
// contribution. It is a SINGLE Shamir point of one party's secret, addressed to
// exactly one recipient — never the secret itself, and never another party's
// share. Any t-1 of these reveal nothing about the dealer's contribution.
//
// Deliberately contains NO *rlwe.SecretKey / *fhe.SecretKey field: the transport
// cannot carry a full secret. (Enforced by dealerless_dkg_test.go's reflect gate.)
type SubShareMsg struct {
From int
To int
Coeffs []uint64 // f^{(From)}_i(To) mod q, for i in [0, N)
}
// DealerlessParty is ONE committee member's private state. It is constructed
// from the member's OWN freshly-sampled secret contribution and never ingests
// another party's secret contribution — only public CKG shares and the Shamir
// sub-shares addressed to it. There is intentionally no method that returns or
// reconstructs the collective secret s.
type DealerlessParty struct {
index int // 1-based Shamir x-coordinate / party identity
threshold, total int
params fhe.Parameters
paramsLWE rlwe.Parameters
ckg multiparty.PublicKeyGenProtocol
// si is this party's OWN secret contribution s_j. Private; never
// serialized and never leaves the node. Zeroized by Zeroize().
si *rlwe.SecretKey
}
// NewDealerlessParty creates party `index` (1-based) for a (threshold, total)
// committee and samples its OWN secret contribution s_index. Each party must be
// constructed locally on its own node; the constructor performs no I/O.
func NewDealerlessParty(index, threshold, total int, params fhe.Parameters) (*DealerlessParty, error) {
if index < 1 || index > total {
return nil, fmt.Errorf("threshold/dkg: party index %d out of range [1,%d]", index, total)
}
if threshold < 1 || threshold > total {
return nil, fmt.Errorf("threshold/dkg: bad threshold %d for total %d", threshold, total)
}
paramsLWE := params.ParamsLWE()
if uint64(total) >= paramsLWE.Q()[0] {
return nil, fmt.Errorf("threshold/dkg: total %d exceeds LWE modulus %d", total, paramsLWE.Q()[0])
}
// Each party samples its OWN contribution with the library's exact secret
// distribution, so the collective key is well-formed for the FHE scheme.
si := rlwe.NewKeyGenerator(paramsLWE).GenSecretKeyNew()
return &DealerlessParty{
index: index,
threshold: threshold,
total: total,
params: params,
paramsLWE: paramsLWE,
ckg: multiparty.NewPublicKeyGenProtocol(paramsLWE),
si: si,
}, nil
}
// Index returns this party's 1-based identity.
func (p *DealerlessParty) Index() int { return p.index }
// SampleDealerlessCRP derives the shared common reference polynomial a from a
// consensus seed and returns it together with a protocol handle. Every
// validator calls this with the identical seed and obtains the identical a, so
// the collective public key is deterministic across the network.
func SampleDealerlessCRP(params fhe.Parameters, crsSeed []byte) (multiparty.PublicKeyGenCRP, multiparty.PublicKeyGenProtocol, error) {
if len(crsSeed) == 0 {
return multiparty.PublicKeyGenCRP{}, multiparty.PublicKeyGenProtocol{}, fmt.Errorf("threshold/dkg: empty CRS seed")
}
crs, err := sampling.NewKeyedPRNG(crsSeed)
if err != nil {
return multiparty.PublicKeyGenCRP{}, multiparty.PublicKeyGenProtocol{}, fmt.Errorf("threshold/dkg: keyed prng: %w", err)
}
ckg := multiparty.NewPublicKeyGenProtocol(params.ParamsLWE())
crp := ckg.SampleCRP(crs)
return crp, ckg, nil
}
// DealRound1 produces this party's two outputs:
//
// - its PUBLIC CKG contribution p_index = -a·s_index + e_index, and
// - one PRIVATE Shamir sub-share per recipient (including itself), being the
// coefficient-wise degree-(t-1) Shamir dealing of its OWN s_index.
//
// The party's secret contribution s_index is used here and only here; it never
// leaves the node. The full collective secret is never formed.
func (p *DealerlessParty) DealRound1(crp multiparty.PublicKeyGenCRP) (PublicKeyShareMsg, []SubShareMsg, error) {
if p.si == nil {
return PublicKeyShareMsg{}, nil, fmt.Errorf("threshold/dkg: party %d already finalized (secret zeroized)", p.index)
}
// Public CKG contribution toward the collective encryption key.
share := p.ckg.AllocateShare()
p.ckg.GenShare(p.si, crp, &share)
// Private coefficient-wise Shamir dealing of this party's OWN contribution.
stdCoeffs := extractStdCoeffs(p.paramsLWE, p.si)
sub, err := dealCoeffSubShares(stdCoeffs, p.paramsLWE.Q()[0], p.threshold, p.total)
zeroU64(stdCoeffs)
if err != nil {
return PublicKeyShareMsg{}, nil, err
}
msgs := make([]SubShareMsg, p.total)
for r := 0; r < p.total; r++ {
msgs[r] = SubShareMsg{From: p.index, To: r + 1, Coeffs: sub[r]}
}
return PublicKeyShareMsg{From: p.index, Share: share}, msgs, nil
}
// Aggregate folds the Shamir sub-shares addressed to THIS party (one from each
// dealer, including itself) into the party's t-of-n LWEShare of the collective
// secret s. The result is a Shamir share of s; s itself is never reconstructed.
func (p *DealerlessParty) Aggregate(inbound []SubShareMsg) (LWEShare, error) {
q := p.paramsLWE.Q()[0]
N := p.paramsLWE.RingQ().N()
coeffs := make([]uint64, N)
seen := make(map[int]struct{}, len(inbound))
for _, m := range inbound {
if m.To != p.index {
return LWEShare{}, fmt.Errorf("threshold/dkg: sub-share addressed to %d delivered to party %d", m.To, p.index)
}
if _, dup := seen[m.From]; dup {
return LWEShare{}, fmt.Errorf("threshold/dkg: duplicate sub-share from dealer %d", m.From)
}
seen[m.From] = struct{}{}
if len(m.Coeffs) != N {
return LWEShare{}, fmt.Errorf("threshold/dkg: sub-share length %d != ring N %d", len(m.Coeffs), N)
}
addModQ(coeffs, m.Coeffs, q)
}
if len(seen) != p.total {
return LWEShare{}, fmt.Errorf("threshold/dkg: party %d expected %d sub-shares, got %d", p.index, p.total, len(seen))
}
return LWEShare{Index: p.index, Coeffs: coeffs, Q: q, Total: p.total}, nil
}
// Zeroize erases this party's secret contribution. Call once the party has its
// LWEShare; the contribution is no longer needed and must not linger.
func (p *DealerlessParty) Zeroize() {
if p.si != nil {
// ring.Poly.Zero is a no-op when Coeffs is empty (no P level), so both
// parts can be scrubbed unconditionally.
p.si.Value.Q.Zero()
p.si.Value.P.Zero()
p.si = nil
}
}
// AssembleCollectivePublicKey aggregates every party's PUBLIC CKG share into the
// collective LWE public key. All inputs are public; this forms the public key
// p = Σ_j (-a·s_j + e_j) = -a·s + e, never the secret s.
func AssembleCollectivePublicKey(
ckg multiparty.PublicKeyGenProtocol,
crp multiparty.PublicKeyGenCRP,
shares []PublicKeyShareMsg,
params fhe.Parameters,
) (*fhe.PublicKey, error) {
if len(shares) == 0 {
return nil, fmt.Errorf("threshold/dkg: no public-key shares")
}
agg := ckg.AllocateShare()
// agg starts at the zero share; fold each contribution in.
for i := range shares {
ckg.AggregateShares(agg, shares[i].Share, &agg)
}
pkLWE := rlwe.NewPublicKey(params.ParamsLWE())
ckg.GenPublicKey(agg, crp, pkLWE)
return &fhe.PublicKey{PKLWE: pkLWE}, nil
}
// DealerlessKeyGen runs the full t-of-n dealerless ceremony. Each simulated
// party touches ONLY its own secret contribution and the messages addressed to
// it; the function never forms the collective secret s. Returns the collective
// LWE public key for encryption and one LWEShare per party for threshold
// decryption.
//
// This in-process driver is the protocol's executable specification and a
// single-coordinator deployment path. A distributed deployment instead runs the
// per-party DealerlessParty methods on separate nodes (DealRound1 broadcasts the
// public CKG share and unicasts each sub-share; Aggregate folds the inbound
// sub-shares) so that no node ever holds another node's contribution.
func DealerlessKeyGen(params fhe.Parameters, threshold, total int, crsSeed []byte) (*fhe.PublicKey, []LWEShare, error) {
crp, ckg, err := SampleDealerlessCRP(params, crsSeed)
if err != nil {
return nil, nil, err
}
parties := make([]*DealerlessParty, total)
for i := 0; i < total; i++ {
parties[i], err = NewDealerlessParty(i+1, threshold, total, params)
if err != nil {
return nil, nil, err
}
}
// Round 1: every party deals. Public CKG shares are collected; each private
// sub-share is routed to its recipient's inbox. No party's si is ever shared.
pkShares := make([]PublicKeyShareMsg, total)
inboxes := make([][]SubShareMsg, total)
for i, party := range parties {
pkMsg, subs, derr := party.DealRound1(crp)
if derr != nil {
return nil, nil, derr
}
pkShares[i] = pkMsg
for _, s := range subs {
inboxes[s.To-1] = append(inboxes[s.To-1], s)
}
}
// Collective public key from the public shares only.
pub, err := AssembleCollectivePublicKey(ckg, crp, pkShares, params)
if err != nil {
return nil, nil, err
}
// Round 2: every party folds its inbox into its LWEShare of s.
shares := make([]LWEShare, total)
for i, party := range parties {
shares[i], err = party.Aggregate(inboxes[i])
if err != nil {
return nil, nil, err
}
party.Zeroize()
}
return pub, shares, nil
}
// --- shared dealing helpers (used by the dealerless DKG and, for DRY, by the
// trusted-dealer ShareLWESecretKey) ---
// extractStdCoeffs lifts an rlwe secret key from its stored NTT+Montgomery form
// back to standard coefficient form in [0, q), returning a fresh copy. The
// scratch polynomial is zeroized before return.
func extractStdCoeffs(paramsLWE rlwe.Parameters, sk *rlwe.SecretKey) []uint64 {
ringQ := paramsLWE.RingQ()
skStd := ringQ.NewPoly()
ringQ.IMForm(sk.Value.Q, skStd)
ringQ.INTT(skStd, skStd)
out := append([]uint64(nil), skStd.Coeffs[0]...)
skStd.Zero()
return out
}
// dealCoeffSubShares Shamir-splits a single secret polynomial (given in standard
// coefficient form) coefficient-by-coefficient over Z_q. For each recipient r in
// 1..total it returns a length-N vector whose i-th entry is f_i(r), where f_i is
// a fresh degree-(threshold-1) polynomial with f_i(0) = stdCoeffs[i] mod q. The
// degree-(t-1) masking coefficients are sampled with crypto/rand.
func dealCoeffSubShares(stdCoeffs []uint64, q uint64, threshold, total int) ([][]uint64, error) {
if threshold < 1 || threshold > total {
return nil, fmt.Errorf("threshold/dkg: bad threshold %d for total %d", threshold, total)
}
qBig := new(big.Int).SetUint64(q)
N := len(stdCoeffs)
out := make([][]uint64, total)
for r := 0; r < total; r++ {
out[r] = make([]uint64, N)
}
for i := 0; i < N; i++ {
coeffs := make([]*big.Int, threshold)
coeffs[0] = new(big.Int).SetUint64(stdCoeffs[i] % q)
for k := 1; k < threshold; k++ {
rnd, err := rand.Int(rand.Reader, qBig)
if err != nil {
return nil, fmt.Errorf("threshold/dkg: random coeff: %w", err)
}
coeffs[k] = rnd
}
for r := 0; r < total; r++ {
x := new(big.Int).SetInt64(int64(r + 1))
out[r][i] = evalPolyModQ(coeffs, x, qBig)
}
}
return out, nil
}
// addModQ computes dst[i] = (dst[i] + src[i]) mod q in place.
func addModQ(dst, src []uint64, q uint64) {
for i := range dst {
// Both operands are < q, so a single conditional subtraction suffices
// without overflow for q < 2^63.
s := dst[i] + src[i]
if s >= q {
s -= q
}
dst[i] = s
}
}
// zeroU64 scrubs a uint64 slice holding sensitive coefficients.
func zeroU64(b []uint64) {
for i := range b {
b[i] = 0
}
}
-442
View File
@@ -1,442 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package threshold
import (
"go/ast"
"go/parser"
"go/token"
"math/big"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/utils/sampling"
)
// crsSeed is a fixed CRS seed for the tests. In production this is supplied by
// consensus (e.g. a beacon/epoch nonce) and is identical on every validator.
var crsSeed = []byte("lux-fchain-tfhe-dealerless-dkg-test-crs-seed-v1")
// ============================================================================
// E2E: the trustless confidential-decrypt lane end to end.
// dealerless DKG → encrypt under collective pk → t-of-n partial decrypt
// → combine → correct plaintext, with NO trusted dealer.
// ============================================================================
// TestDealerless_E2E_RoundTrip_2of3 runs the full trustless flow at PN10QP27.
func TestDealerless_E2E_RoundTrip_2of3(t *testing.T) {
for _, value := range []bool{false, true} {
runDealerlessRoundTrip(t, fhe.PN10QP27, 2, 3, value)
}
}
// TestDealerless_E2E_RoundTrip_3of5 covers a larger committee at PN11QP54
// (wider modulus for the larger collective secret + public-key noise).
func TestDealerless_E2E_RoundTrip_3of5(t *testing.T) {
if testing.Short() {
t.Skip("skipping 3-of-5 dealerless round-trip under -short")
}
for _, value := range []bool{false, true} {
runDealerlessRoundTrip(t, fhe.PN11QP54, 3, 5, value)
}
}
func runDealerlessRoundTrip(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)
}
// DEALERLESS key generation: no trusted dealer ever holds the FHE secret.
pub, shares, err := DealerlessKeyGen(params, threshold, total, crsSeed)
if err != nil {
t.Fatalf("dealerless keygen: %v", err)
}
if len(shares) != total {
t.Fatalf("share count: got %d want %d", len(shares), total)
}
if pub == nil || pub.PKLWE == nil {
t.Fatalf("nil collective public key")
}
// Encrypt under the COLLECTIVE PUBLIC KEY (public-key encryption — the
// production path; no secret key anywhere).
enc := fhe.NewBitwisePublicEncryptor(params, pub)
ct, err := enc.Encrypt(value)
if err != nil {
t.Fatalf("public-key encrypt: %v", err)
}
// Threshold decrypt with the LAST t parties (avoids lucky small-Lagrange
// alignment with the first shares).
subset := shares[total-threshold:]
partials := make([]*LWEPartialDecryption, threshold)
for i := range subset {
share := subset[i]
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)
}
partials[i] = p
}
got, err := CombineFHE(ct, partials, params)
if err != nil {
t.Fatalf("combine: %v", err)
}
if got != value {
t.Fatalf("dealerless round trip: got %v want %v (%d-of-%d)", got, value, threshold, total)
}
}
// TestDealerless_BelowThresholdFails confirms t-1 parties cannot recover the
// plaintext: a single partial leaves c_1·s_j unmasked, so the decoded bit is
// wrong with non-negligible probability.
func TestDealerless_BelowThresholdFails(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
pub, shares, err := DealerlessKeyGen(params, 2, 3, crsSeed)
if err != nil {
t.Fatalf("keygen: %v", err)
}
enc := fhe.NewBitwisePublicEncryptor(params, pub)
disagreements := 0
trials := 32
share := shares[0]
for i := 0; i < trials; i++ {
value := i&1 == 0
ct, err := enc.Encrypt(value)
if err != nil {
t.Fatalf("encrypt[%d]: %v", i, err)
}
prng, _ := sampling.NewPRNG()
p, err := PartialDecryptFHE(&share, ct, params, 1, prng)
if err != nil {
t.Fatalf("partial[%d]: %v", i, err)
}
got, err := CombineFHE(ct, []*LWEPartialDecryption{p}, params)
if err != nil {
t.Fatalf("combine[%d]: %v", i, err)
}
if got != value {
disagreements++
}
}
if disagreements == 0 {
t.Fatalf("below-threshold (1 of 3) must not reliably decrypt; got 0/%d disagreements", trials)
}
}
// ============================================================================
// DEALERLESS / NO-PARTY-HOLDS-SECRET (behavioural).
// ============================================================================
// TestDealerless_NoPartyHoldsSecret proves the shares form a genuine t-of-n
// Shamir sharing of a well-defined secret that NO single party holds:
// - two disjoint-enough t-subsets reconstruct the SAME secret (a consistent
// sharing of one secret exists), and
// - no individual party's share equals that secret.
// The reconstruction oracle lives ONLY in this test file; production never
// reconstructs s (see TestDealerless_NoReconstruct_Structural).
func TestDealerless_NoPartyHoldsSecret(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
_, shares, err := DealerlessKeyGen(params, 2, 3, crsSeed)
if err != nil {
t.Fatalf("keygen: %v", err)
}
q := params.QLWE()
// Reconstruct from {party1,party2} and from {party2,party3}; both must agree
// — the shares are a consistent Shamir sharing of one secret.
sA := reconstructSecretForTest(t, []LWEShare{shares[0], shares[1]}, q)
sB := reconstructSecretForTest(t, []LWEShare{shares[1], shares[2]}, q)
if !equalU64(sA, sB) {
t.Fatalf("two t-subsets reconstruct different secrets: shares are not a consistent sharing")
}
// No single share equals the secret — no party holds s.
for _, share := range shares {
if equalU64(share.Coeffs, sA) {
t.Fatalf("party %d's share equals the reconstructed secret — a party holds s", share.Index)
}
}
// And the reconstructed secret is non-trivial (not all-zero), i.e. a real
// secret was shared.
allZero := true
for _, c := range sA {
if c != 0 {
allZero = false
break
}
}
if allZero {
t.Fatalf("reconstructed secret is all-zero — degenerate sharing")
}
}
// TestDealerless_CRP_Deterministic proves the public CRP a is identical for the
// same consensus seed (so every validator derives the same collective-key
// algebra), while differing seeds give different a. The SECRET is freshly
// sampled per run (trustless) — only the public reference is seed-derived.
func TestDealerless_CRP_Deterministic(t *testing.T) {
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("params: %v", err)
}
crp1, _, err := SampleDealerlessCRP(params, crsSeed)
if err != nil {
t.Fatalf("crp1: %v", err)
}
crp2, _, err := SampleDealerlessCRP(params, crsSeed)
if err != nil {
t.Fatalf("crp2: %v", err)
}
if !crp1.Value.Q.Equal(&crp2.Value.Q) {
t.Fatalf("CRP not deterministic for identical seed — validators would diverge")
}
crp3, _, err := SampleDealerlessCRP(params, []byte("a-different-consensus-seed"))
if err != nil {
t.Fatalf("crp3: %v", err)
}
if crp1.Value.Q.Equal(&crp3.Value.Q) {
t.Fatalf("CRP identical for different seeds — CRS is not seed-bound")
}
}
// ============================================================================
// TRANSPORT CARRIES NO SECRET (reflect gate).
// The dealerless protocol messages must carry only public/share data — never a
// full secret key. A *rlwe.SecretKey or *fhe.SecretKey field on a wire type
// would let a single party's secret leave its node.
// ============================================================================
func TestDealerless_TransportCarriesNoSecret(t *testing.T) {
forbidden := []string{"SecretKey", "SKLWE", "SKBR"}
for _, msg := range []interface{}{PublicKeyShareMsg{}, SubShareMsg{}, LWEShare{}, LWEPartialDecryption{}} {
ty := reflect.TypeOf(msg)
for i := 0; i < ty.NumField(); i++ {
f := ty.Field(i)
fieldStr := f.Name + ":" + f.Type.String()
for _, bad := range forbidden {
if strings.Contains(fieldStr, bad) {
t.Fatalf("wire type %s field %q references forbidden secret material (%q)", ty.Name(), fieldStr, bad)
}
}
}
}
}
// ============================================================================
// NO-RECONSTRUCT (structural, go/ast).
// The dealerless-keygen + threshold-decrypt path must never invoke a
// trusted-dealer keygen (forms/holds a full secret) nor a secret-reconstruction
// primitive. Modelled on corona's no_reconstruct_sign_test.go GATE A.
// ============================================================================
// noReconstructForbidden lists call targets that would mean the trustless path
// formed or reconstructed the full FHE secret key:
// - GenKeyPair: generates the full master (sk, pk) in one process.
// - ShareLWESecretKey / ShareLWESecretKeyFHE: deal a *whole* secret key (the
// trusted-dealer split; the dealerless path never deals a full key).
// - GenerateSharedKey: the trusted-dealer keygen wrapper.
// Plus any identifier whose name denotes secret reconstruction.
var noReconstructForbiddenCalls = map[string]struct{}{
"GenKeyPair": {},
"ShareLWESecretKey": {},
"ShareLWESecretKeyFHE": {},
"GenerateSharedKey": {},
}
var noReconstructForbiddenNameRe = regexp.MustCompile(`(?i)reconstruct|recoversecret|combinesecret|recovermaster`)
// noReconstructScannedFuncs is the trustless key-generation + threshold-decrypt
// surface that must be free of trusted-dealer / reconstruction calls.
var noReconstructScannedFuncs = map[string]struct{}{
"DealRound1": {},
"Aggregate": {},
"AssembleCollectivePublicKey": {},
"DealerlessKeyGen": {},
"NewDealerlessParty": {},
"PartialDecryptLWE": {},
"CombineLWE": {},
"PartialDecryptFHE": {},
"CombineFHE": {},
}
func TestDealerless_NoReconstruct_Structural(t *testing.T) {
// Scan PRODUCTION source only (exclude _test.go): the gate is about the
// shipped trustless path, not test scaffolding.
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatalf("glob: %v", err)
}
fset := token.NewFileSet()
scanned := map[string]bool{}
for _, path := range files {
if strings.HasSuffix(path, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
if _, want := noReconstructScannedFuncs[fn.Name.Name]; !want {
continue
}
scanned[fn.Name.Name] = true
if bad := findForbiddenCall(fn.Body); bad != "" {
t.Errorf("function %s calls forbidden primitive %q — trustless path may form/reconstruct the full FHE secret",
fn.Name.Name, bad)
}
}
}
// Every function we expect to scan must have been found — a rename that
// silently drops a function from the gate is itself a failure.
for name := range noReconstructScannedFuncs {
if !scanned[name] {
t.Errorf("expected to scan function %q but it was not found (renamed/removed?)", name)
}
}
}
// TestDealerless_NoReconstruct_Structural_NegativeControl proves the gate has
// teeth: a synthetic function body containing a forbidden call is flagged.
func TestDealerless_NoReconstruct_Structural_NegativeControl(t *testing.T) {
src := `package x
func victim() {
sk := genFull()
ShareLWESecretKey(sk, params, 2, 3) // trusted-dealer deal — must be caught
}`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "synthetic.go", src, 0)
if err != nil {
t.Fatalf("parse synthetic: %v", err)
}
var body *ast.BlockStmt
for _, decl := range f.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "victim" {
body = fn.Body
}
}
if body == nil {
t.Fatal("synthetic victim not parsed")
}
if bad := findForbiddenCall(body); bad == "" {
t.Fatal("negative control: gate failed to flag an injected forbidden call (gate has no teeth)")
}
}
// findForbiddenCall returns the name of the first forbidden call target found in
// body, or "" if clean. Matches both bare calls f(...) and selector calls x.f(...).
func findForbiddenCall(body *ast.BlockStmt) string {
found := ""
ast.Inspect(body, func(n ast.Node) bool {
if found != "" {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
var name string
switch fn := call.Fun.(type) {
case *ast.Ident:
name = fn.Name
case *ast.SelectorExpr:
name = fn.Sel.Name
}
if name == "" {
return true
}
if _, bad := noReconstructForbiddenCalls[name]; bad {
found = name
return false
}
if noReconstructForbiddenNameRe.MatchString(name) {
found = name
return false
}
return true
})
return found
}
// ============================================================================
// test-only reconstruction oracle (NOT in the production binary).
// ============================================================================
// reconstructSecretForTest Lagrange-interpolates the LWEShares at x=0 over Z_q
// to recover the shared secret in standard coefficient form. It exists solely to
// PROVE properties of the sharing (consistency, no-party-holds); production code
// never does this — the structural gate forbids it.
func reconstructSecretForTest(t *testing.T, shares []LWEShare, q uint64) []uint64 {
t.Helper()
qBig := new(big.Int).SetUint64(q)
N := len(shares[0].Coeffs)
out := make([]uint64, N)
for i := 0; i < N; i++ {
acc := new(big.Int)
for j := range shares {
xj := big.NewInt(int64(shares[j].Index))
num := big.NewInt(1)
den := big.NewInt(1)
for k := range shares {
if k == j {
continue
}
xk := big.NewInt(int64(shares[k].Index))
num.Mul(num, xk)
num.Mod(num, qBig)
d := new(big.Int).Sub(xk, xj)
den.Mul(den, d)
den.Mod(den, qBig)
}
denInv := new(big.Int).ModInverse(den, qBig)
if denInv == nil {
t.Fatalf("non-invertible Lagrange denominator (duplicate indices?)")
}
lambda := new(big.Int).Mul(num, denInv)
lambda.Mod(lambda, qBig)
term := new(big.Int).Mul(lambda, new(big.Int).SetUint64(shares[j].Coeffs[i]))
acc.Add(acc, term)
acc.Mod(acc, qBig)
}
out[i] = acc.Uint64()
}
return out
}
func equalU64(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
-55
View File
@@ -1,55 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// Package threshold is the ONE threshold-FHE implementation for Lux — the
// F-Chain confidential-decrypt lane. It is held to the trustless-by-default law:
// no trusted dealer (dealerless key generation) and no reconstruction of the FHE
// secret key at decrypt time, the same standard Corona / Pulsar / Magnetar meet.
//
// There is exactly one way to do each thing here. The canonical trustless flow:
//
// pub, shares := DealerlessKeyGen(params, t, n, crsSeed) // dealerless keygen
// ct := encrypt(pub, m) // collective public key
// d_j := PartialDecryptLWE(shares[j], ct, …) // per-party, no s formed
// m := CombineLWE(ct, {d_j}_{|S|≥t}, …) // s masked behind c_1, never recovered
//
// File map (decomplected — each file is one concern, no overlap):
//
// - dealerless_dkg.go — THE production key generation. Dealerless (GJKR/Pedersen
// DKG composed with the audited Lattigo multiparty CKG): each committee
// member samples only its own contribution s_j, the collective secret
// s = Σ s_j is NEVER formed, and the output is the collective LWE public key
// plus one t-of-n LWEShare per party.
//
// - partial_decrypt.go — THE threshold decryption (AJL+12 noise-flooding with
// Bendlin-Damgård denominator clearing). PartialDecryptLWE / CombineLWE are
// no-reconstruct: the secret s appears only as the coefficient of the public
// c_1 inside the masked aggregate and is never materialised. ShareLWESecretKey
// in this file is a TRUSTED-DEALER split of a whole key — retained only as a
// test/KAT oracle for the same LWEShare type; production uses DealerlessKeyGen.
//
// - lsss.go + keygen.go — a SEPARATE primitive: large-prime Shamir VSS with
// Feldman commitments, and GenerateSharedKey, which shares the SHA-256 *hash*
// of a serialized key. This is a key-COMMITMENT / verifiable-secret-sharing
// facility (audit, dealer-equivocation checks). Its KeyShare values are shares
// of a hash and are NOT usable for threshold DECRYPTION — do not mistake
// GenerateSharedKey for the threshold-FHE keygen; that is DealerlessKeyGen.
//
// Scope and residual. This package realises the trustless confidential-DECRYPT
// lane completely: dealerless generation of the encryption key + t-of-n shares,
// and no-reconstruct threshold decryption. It deliberately does NOT generate the
// FHEW blind-rotation / bootstrap (evaluation) key, which homomorphic COMPUTE on
// committee ciphertexts requires. Dealerless bootstrap-key generation is a
// distinct multiparty-FHEW protocol (collaborative RGSW key generation) and is
// the documented residual — it is not faked here. Until it lands, the committee
// can trustlessly ENCRYPT (collective public key) and trustlessly threshold-
// DECRYPT (the launch-critical confidentiality property); homomorphic compute on
// the committee's own ciphertexts still depends on a bootstrap key generated by a
// dealer and is therefore out of the strict trustless lane.
//
// Decomplecting boundary (F ⊥ M). This package is FHE only (confidential
// decrypt). It shares nothing with threshold MPC SIGNING (M-Chain: CGGMP21 /
// FROST for bridge custody), which lives in github.com/luxfi/threshold. The two
// are orthogonal primitives — different math, different purpose — and must never
// be conflated.
package threshold
-615
View File
@@ -1,615 +0,0 @@
// 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 (
"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.
//
// TRUSTED-DEALER. This takes a WHOLE secret key, so the caller momentarily holds
// the full FHE secret — a single point of compromise. It is retained as a
// test/KAT oracle for the LWEShare type; the trustless PRODUCTION key generation
// is DealerlessKeyGen (dealerless_dkg.go), where no party ever holds the secret.
// Both produce the same LWEShare via the same dealing helpers (dealCoeffSubShares).
//
// 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)
}
// 1) Extract the secret key to standard coefficient form. (Shared helper
// extractStdCoeffs mirrors genSecretKeyFromSampler in reverse: IMForm
// then INTT, returning a fresh copy and zeroing its scratch.)
stdCoeffs := extractStdCoeffs(params, skLWE)
// 2) Share s itself (not Δ·s) via the SAME coefficient-wise dealing used by
// the dealerless DKG (dealCoeffSubShares). 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 (see SmudgingSigma).
//
// The ONLY difference from the dealerless path: here a single trusted
// dealer holds the whole skLWE and deals it; in DealerlessKeyGen each
// party deals only its own contribution and the shares are summed. The
// dealing math is identical — hence shared — but the trust model is not.
sub, err := dealCoeffSubShares(stdCoeffs, q, threshold, total)
zeroU64(stdCoeffs)
if err != nil {
return nil, err
}
shares := make([]LWEShare, total)
for j := 0; j < total; j++ {
shares[j] = LWEShare{
Index: j + 1,
Coeffs: sub[j],
Q: q,
Total: total,
}
}
return shares, nil
}
// evalPolyModQ evaluates coeffs[0] + coeffs[1]·x + ... + coeffs[t-1]·x^(t-1)
// modulo q using Horner's method, returning a uint64 in [0, q).
func evalPolyModQ(coeffs []*big.Int, x, q *big.Int) uint64 {
acc := new(big.Int).Set(coeffs[len(coeffs)-1])
for k := len(coeffs) - 2; k >= 0; k-- {
acc.Mul(acc, x)
acc.Add(acc, coeffs[k])
acc.Mod(acc, q)
}
if acc.Sign() < 0 {
acc.Add(acc, q)
}
return acc.Uint64()
}
// PartialDecryptLWE produces party j's partial decryption d_j of the LWE
// ciphertext ct under their LWEShare. The output is in NTT domain at
// level 0.
//
// d_j = c_1 · s_j + e_j (mod q, NTT)
//
// where c_1 is ct.Value[1] (already in NTT per rlwe convention), s_j is
// the share polynomial NTT'd into the LWE ring, and e_j is sampled from
// a discrete Gaussian with the supplied σ. The share polynomial is
// reduced into the LWE ring on the fly; the caller must not pre-NTT it.
//
// IMPORTANT: every call to PartialDecryptLWE on the same ciphertext with
// the same share *must* produce a new e_j. Reusing e_j across calls
// completely breaks the noise-flooding masking. The caller-supplied prng
// is responsible for this freshness; we wire a fresh PRNG per call to
// make the contract explicit.
func PartialDecryptLWE(
share *LWEShare,
ct *rlwe.Ciphertext,
params rlwe.Parameters,
sigma float64,
prng sampling.PRNG,
) (*LWEPartialDecryption, error) {
if share == nil {
return nil, fmt.Errorf("threshold/lwe: nil share")
}
q := params.Q()[0]
if share.Q != q {
return nil, fmt.Errorf("threshold/lwe: share q=%d does not match params q=%d", share.Q, q)
}
ringQ := params.RingQ().AtLevel(0)
if got := len(share.Coeffs); got != ringQ.N() {
return nil, fmt.Errorf("threshold/lwe: share length %d != ring N %d", got, ringQ.N())
}
// 1) Lift the share into the lattice ring representation. We start in
// coefficient form, NTT, then MForm so the Montgomery-domain
// multiplication MulCoeffsMontgomery composes correctly.
sPoly := ringQ.NewPoly()
copy(sPoly.Coeffs[0], share.Coeffs)
ringQ.NTT(sPoly, sPoly)
ringQ.MForm(sPoly, sPoly)
// 2) Compute c_1 · s_j in NTT/Montgomery domain.
// rlwe stores ciphertexts with Value[1] in NTT form already.
c1 := ct.Value[1]
if !ct.IsNTT {
// Defensive: NTT into a buffer rather than mutating the caller's
// ciphertext. The standard fhe.Encryptor produces NTT ciphertexts
// (see encryptor.go addPtToCt + lwe encoding), but we handle both.
buf := ringQ.NewPoly()
ringQ.NTT(c1, buf)
c1 = buf
}
dPoly := ringQ.NewPoly()
ringQ.MulCoeffsMontgomery(c1, sPoly, dPoly)
// 3) Sample smudging noise e_j ∈ R_q with discrete-Gaussian σ.
// We sample in standard form, NTT, then add to d_j. Bound is set
// to 6σ (truncated Gaussian — matches lattice/ring conventions).
bound := 6.0 * sigma
if bound > float64(q)/2 {
bound = float64(q) / 2
}
xeSampler, err := ring.NewSampler(prng, ringQ, ring.DiscreteGaussian{
Sigma: sigma,
Bound: bound,
}, false)
if err != nil {
return nil, fmt.Errorf("threshold/lwe: noise sampler: %w", err)
}
ePoly := ringQ.NewPoly()
xeSampler.AtLevel(0).Read(ePoly)
ringQ.NTT(ePoly, ePoly)
ringQ.Add(dPoly, ePoly, dPoly)
// Zero the share's lattice-ring lift; the persistent share is in
// coefficient form (share.Coeffs) and was untouched.
sPoly.Zero()
ePoly.Zero()
out := &LWEPartialDecryption{
Index: share.Index,
Value: make([]uint64, ringQ.N()),
Q: q,
Total: share.Total,
}
copy(out.Value, dPoly.Coeffs[0])
return out, nil
}
// CombineLWE Lagrange-interpolates ≥ t partial decryptions and finishes
// the recovery, returning the recovered plaintext polynomial in NTT.
//
// Under Bendlin-Damgård denominator clearing we recover Δ·m where Δ =
// total!:
//
// Δ · pt = Δ · c_0 + Σ_j Λ_j · d_j (NTT)
//
// where Λ_j = Δ · λ_j ∈ ℤ are *integer* Lagrange numerators, and the
// noise stays bounded by |Σ_j Λ_j · e_j| ≤ Δ · √t · 6σ ≤ Q/16. After
// summing in Z_q we multiply by Δ^{-1} mod q to peel the scaling off
// and return the canonical plaintext polynomial.
//
// Returns the plaintext polynomial in NTT form. Apply INTT then read
// coefficient 0 for bit ciphertexts to recover the bit.
//
// Errors out if any partial's Q or Total disagrees with the supplied
// params, if indices repeat, or if fewer than 1 partial is supplied.
// The threshold itself is enforced by the caller — combine does not
// know t. Re-using partials beyond their Total field's value is rejected
// to catch cross-keygen mixing.
func CombineLWE(
ct *rlwe.Ciphertext,
partials []*LWEPartialDecryption,
params rlwe.Parameters,
) (*rlwe.Plaintext, error) {
if len(partials) < 1 {
return nil, fmt.Errorf("threshold/lwe: no partials supplied")
}
q := params.Q()[0]
total := partials[0].Total
for _, p := range partials {
if p == nil {
return nil, fmt.Errorf("threshold/lwe: nil partial")
}
if p.Q != q {
return nil, fmt.Errorf("threshold/lwe: partial q=%d != params q=%d", p.Q, q)
}
if p.Total != total {
return nil, fmt.Errorf("threshold/lwe: partial total=%d differs from %d", p.Total, total)
}
}
seen := make(map[int]struct{}, len(partials))
for _, p := range partials {
if _, dup := seen[p.Index]; dup {
return nil, fmt.Errorf("threshold/lwe: duplicate partial index %d", p.Index)
}
seen[p.Index] = struct{}{}
}
ringQ := params.RingQ().AtLevel(0)
qBig := new(big.Int).SetUint64(q)
delta := deltaFactorial(total)
deltaMod := new(big.Int).Mod(delta, qBig)
// Accumulator starts at Δ · c_0 in NTT form (since the right-hand
// side of the Bendlin-Damgård identity is Δ · c_0 + Σ_j Λ_j d_j).
accum := ringQ.NewPoly()
c0 := ct.Value[0]
if ct.IsNTT {
accum.Coeffs[0] = append([]uint64(nil), c0.Coeffs[0]...)
} else {
// Defensive NTT into a buffer if the ciphertext is non-NTT.
ringQ.NTT(c0, accum)
}
ringQ.MulScalar(accum, deltaMod.Uint64(), accum)
// For each partial: Λ_j (integer, then mod q for ring scalar mul),
// then accum += Λ_j · d_j in NTT.
scratch := ringQ.NewPoly()
for i, pi := range partials {
lambdaInt := lagrangeIntegerNumeratorAtZero(partials, i, delta)
// Reduce mod q for the lattice ring-scalar multiply.
lambdaMod := new(big.Int).Mod(lambdaInt, qBig)
if lambdaMod.Sign() < 0 {
lambdaMod.Add(lambdaMod, qBig)
}
// Lift partial into a poly, then scale by Λ_j mod q.
copy(scratch.Coeffs[0], pi.Value)
ringQ.MulScalar(scratch, lambdaMod.Uint64(), scratch)
ringQ.Add(accum, scratch, accum)
}
scratch.Zero()
// Divide out Δ to recover m: multiply accum by Δ^{-1} mod q.
deltaInv := new(big.Int).ModInverse(deltaMod, qBig)
if deltaInv == nil {
return nil, fmt.Errorf("threshold/lwe: Δ has no inverse mod q (total %d, q %d)", total, q)
}
ringQ.MulScalar(accum, deltaInv.Uint64(), accum)
pt := rlwe.NewPlaintext(params, 0)
pt.Value.Coeffs[0] = accum.Coeffs[0]
pt.IsNTT = true
return pt, nil
}
// lagrangeIntegerNumeratorAtZero returns Λ_i = Δ · λ_i evaluated at 0
// for the supplied subset of partials, where Δ is total! (assumed to
// equal delta). Λ_i is an *exact integer* by Bendlin-Damgård §4.2;
// |Λ_i| ≤ Δ for any subset of size t ≤ total.
//
// λ_i = ∏_{j≠i} (-x_j) / (x_i - x_j), and Λ_i = Δ · λ_i. We compute
// the integer numerator N_i = ∏(-x_j) and denominator D_i = ∏(x_i - x_j),
// then return Δ · N_i / D_i as a big.Int (the division is exact when
// Δ = total!).
func lagrangeIntegerNumeratorAtZero(partials []*LWEPartialDecryption, i int, delta *big.Int) *big.Int {
xi := new(big.Int).SetInt64(int64(partials[i].Index))
num := big.NewInt(1)
den := big.NewInt(1)
for j, pj := range partials {
if j == i {
continue
}
xj := new(big.Int).SetInt64(int64(pj.Index))
// num *= (-xj) — full integer product, no mod.
neg := new(big.Int).Neg(xj)
num.Mul(num, neg)
// den *= (xi - xj)
diff := new(big.Int).Sub(xi, xj)
den.Mul(den, diff)
}
// Λ = Δ · num / den. The division is exact because den divides Δ:
// den = ∏_{j≠i}(x_i - x_j) divides ∏_{k=1..total}(x_i - k) which
// divides total! · (a unit). The standard B-D argument.
t := new(big.Int).Mul(delta, num)
q, r := new(big.Int).QuoRem(t, den, new(big.Int))
if r.Sign() != 0 {
// Should never happen if delta = total! and indices ⊂ {1..total}.
// Panic loudly — wrong delta would silently break security.
panic(fmt.Sprintf("threshold/lwe: non-exact integer Lagrange numerator (delta=%s, t=%s, den=%s, r=%s)", delta, t, den, r))
}
return q
}
// DecryptBitFromPlaintext decodes a single bit from the recovered
// plaintext polynomial, applying luxfi/fhe's encoding scheme: true ↦ Q/8,
// false ↦ 7Q/8. After INTT, coefficient 0 lies near +Q/8 or -Q/8; we
// return true iff it lies in [0, Q/2). Mirrors fhe.Decryptor.Decrypt.
func DecryptBitFromPlaintext(pt *rlwe.Plaintext, params rlwe.Parameters) bool {
ringQ := params.RingQ().AtLevel(0)
if pt.IsNTT {
// INTT into a buffer so we don't mutate the caller's plaintext.
out := ringQ.NewPoly()
ringQ.INTT(pt.Value, out)
c := out.Coeffs[0][0]
out.Zero()
return c < params.Q()[0]>>1
}
c := pt.Value.Coeffs[0][0]
return c < params.Q()[0]>>1
}
// ShareLWESecretKeyFHE is a thin convenience over ShareLWESecretKey that
// works directly against fhe.Parameters / fhe.SecretKey. Use this from
// out-of-tree callers (notably luxfi/threshold/protocols/tfhe) who already
// have fhe types in hand.
func ShareLWESecretKeyFHE(sk *fhe.SecretKey, params fhe.Parameters, threshold, total int) ([]LWEShare, error) {
return ShareLWESecretKey(sk.SKLWE, params.ParamsLWE(), threshold, total)
}
// PartialDecryptFHE is a thin convenience that operates against the
// fhe.Ciphertext + fhe.Parameters wrapper. Returns a per-bit partial
// decryption sized for the supplied threshold (used only to pick σ).
//
// The σ choice here uses (threshold, share.Total) per Bendlin-Damgård.
// PRNG defaults to a fresh sampling.PRNG when nil — production callers
// pass a process-managed PRNG so smudging noise is logged for audit.
func PartialDecryptFHE(
share *LWEShare,
ct *fhe.Ciphertext,
params fhe.Parameters,
threshold int,
prng sampling.PRNG,
) (*LWEPartialDecryption, error) {
if prng == nil {
var err error
prng, err = sampling.NewPRNG()
if err != nil {
return nil, fmt.Errorf("threshold/lwe: default prng: %w", err)
}
}
sigma := SmudgingSigma(params.ParamsLWE(), threshold, share.Total)
return PartialDecryptLWE(share, ct.Ciphertext, params.ParamsLWE(), sigma, prng)
}
// CombineFHE Lagrange-combines a set of LWEPartialDecryptions and
// decodes a single bit. Convenience over CombineLWE + DecryptBit.
func CombineFHE(
ct *fhe.Ciphertext,
partials []*LWEPartialDecryption,
params fhe.Parameters,
) (bool, error) {
pt, err := CombineLWE(ct.Ciphertext, partials, params.ParamsLWE())
if err != nil {
return false, err
}
return DecryptBitFromPlaintext(pt, params.ParamsLWE()), nil
}
-296
View File
@@ -1,296 +0,0 @@
// 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")
}
}