mirror of
https://github.com/luxfi/fhe.git
synced 2026-09-24 00:27:17 +00:00
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:
+11
-11
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user