mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
feat(threshold): dealerless trustless threshold-FHE keygen (no trusted dealer)
Closes the one trustless gap in the threshold-FHE stack. partial_decrypt.go
was already NO-RECONSTRUCT (the master secret s only ever appears masked behind
c_1·s), but ShareLWESecretKey assumed a TRUSTED DEALER: one process samples the
whole FHE secret key and Shamir-splits it. That instant of single-party custody
violates the trustless-by-default law (no dealer + no-reconstruct), the same
standard Corona/Pulsar/Magnetar are held to.
DealerlessKeyGen removes the dealer. GJKR/Pedersen DKG structure specialised to
the RLWE/LWE secret, composed with the audited Lattigo multiparty CKG:
- each party samples its OWN contribution s_j; collective secret s = Σ s_j
is never formed by any party or by this package;
- collective LWE public key p = Σ(-a·s_j + e_j) = -a·s + e via lattice CKG
(dealerless, format-identical to the library's pk);
- t-of-n shares: each party coeff-wise Shamir-splits its OWN s_j; recipients
sum sub-shares to a degree-(t-1) Shamir share of s — producing exactly the
LWEShare type the proven PartialDecryptLWE/CombineLWE kernel consumes.
Per-party API (DealerlessParty.DealRound1/Aggregate, SampleDealerlessCRP,
AssembleCollectivePublicKey) for distributed deployment + a one-shot driver.
Wire types (PublicKeyShareMsg/SubShareMsg) carry only public/share data, never
a SecretKey. DRY: ShareLWESecretKey now reuses the shared dealing helpers.
Proven (dealerless_dkg_test.go, all green incl. -race, 3-of-5 PN11QP54):
- E2E trustless lane: dealerless DKG → public-key encrypt → t-of-n partial
decrypt → combine → correct plaintext (2-of-3, 3-of-5);
- below-threshold (t-1) cannot decrypt;
- no party holds s: two t-subsets reconstruct the same secret, no share == s;
- CRP deterministic per consensus seed (validators converge), seed-bound;
- transport carries no secret (reflect gate);
- no-reconstruct structural gate (go/ast) over the keygen+decrypt surface,
with a negative control proving the gate has teeth.
Scope: F-Chain confidential-DECRYPT lane (encryption key + t-of-n shares). The
dealerless FHEW bootstrap/eval key (homomorphic COMPUTE) is a separate
multiparty-FHEW protocol, documented as the residual — not faked here.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// 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
|
||||
}
|
||||
@@ -61,7 +61,6 @@
|
||||
package threshold
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
@@ -267,58 +266,39 @@ func ShareLWESecretKey(skLWE *rlwe.SecretKey, params rlwe.Parameters, threshold,
|
||||
if uint64(total) >= q {
|
||||
return nil, fmt.Errorf("threshold/lwe: total %d exceeds LWE modulus %d", total, q)
|
||||
}
|
||||
ringQ := params.RingQ()
|
||||
N := ringQ.N()
|
||||
|
||||
// 1) Extract the secret key to standard coefficient form.
|
||||
// skLWE.Value.Q is NTT + Montgomery; mirror genSecretKeyFromSampler
|
||||
// in reverse: first IMForm then INTT.
|
||||
skStd := ringQ.NewPoly()
|
||||
ringQ.IMForm(skLWE.Value.Q, skStd)
|
||||
ringQ.INTT(skStd, skStd)
|
||||
// skStd.Coeffs[0] now holds the standard coefficients in [0, q).
|
||||
// 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). The Bendlin-Damgård trick is that
|
||||
// the *integer* Lagrange numerators Λ_j = Δ · λ_j satisfy
|
||||
// Σ_j Λ_j · f(j) = Δ · f(0) by linearity; combine therefore
|
||||
// yields Δ · s without any pre-scaling of the secret. The
|
||||
// smudging noise is the only term that picks up a Λ-factor in
|
||||
// magnitude, and we calibrate σ accordingly (see SmudgingSigma).
|
||||
qBig := new(big.Int).SetUint64(q)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 3) For each of the N coefficients, generate a random degree-(t-1)
|
||||
// polynomial whose constant term is s[i] mod q, then evaluate
|
||||
// at x = 1..total.
|
||||
shares := make([]LWEShare, total)
|
||||
for j := 0; j < total; j++ {
|
||||
shares[j] = LWEShare{
|
||||
Index: j + 1,
|
||||
Coeffs: make([]uint64, N),
|
||||
Coeffs: sub[j],
|
||||
Q: q,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
coeffs := make([]*big.Int, threshold)
|
||||
coeffs[0] = new(big.Int).SetUint64(skStd.Coeffs[0][i])
|
||||
for k := 1; k < threshold; k++ {
|
||||
r, err := rand.Int(rand.Reader, qBig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("threshold/lwe: random coeff: %w", err)
|
||||
}
|
||||
coeffs[k] = r
|
||||
}
|
||||
for j := 0; j < total; j++ {
|
||||
x := new(big.Int).SetInt64(int64(j + 1))
|
||||
shares[j].Coeffs[i] = evalPolyModQ(coeffs, x, qBig)
|
||||
}
|
||||
}
|
||||
|
||||
// Zero the local standard-form copy of the secret. The caller still
|
||||
// owns skLWE — they may zero or keep it as the dealer's choice.
|
||||
skStd.Zero()
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user