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:
zeekay
2026-06-28 07:20:07 -07:00
parent 4909871f80
commit bc34033354
3 changed files with 848 additions and 41 deletions
+385
View File
@@ -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
}
}
+442
View File
@@ -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
}
+21 -41
View File
@@ -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
}