Threshold FHE in the default build

The wallet sessions and the encrypted-policy gate run
luxfi/threshold/protocols/tfhe. The experimental_tfhe tag and the
not-implemented stub are gone.

Key generation runs across nodes: each samples its own contribution,
broadcasts the collective-key share and unicasts one Shamir point per peer,
then folds its inbox into a share. The reference polynomial comes from the
wallet and the committee in canonical order, and a message may only speak
for the position of the member that sent it.

Decryption collects contributions bound by digest to the ciphertext and
keeps a round open until they recombine; anything short is an error, which
the verifier reads as a denial. A go/ast gate refuses any call that would
form a whole FHE key.

go.mod needs the two dependency patches tagged, then `go get` and tidy.


Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-17 13:46:52 -07:00
co-authored by Hanzo Dev
parent 52b4270ad6
commit ac06110e68
11 changed files with 1446 additions and 787 deletions
+4 -4
View File
@@ -10,10 +10,10 @@
// This package exists ONLY so the single external consumer that is still
// pinned to a pre-extraction luxfi/mpc — github.com/luxfi/threshold v1.9.9,
// whose pkg/thresholdd/tee_options.go imports github.com/luxfi/mpc/cc/attest
// and references attest.KindNRAS — keeps building. threshold's main branch
// has already dropped this import (the TEE custody code moved to luxfi/tee);
// mpc cannot bump to it yet because mpc/pkg/mpc still imports
// threshold/protocols/tfhe, which that same threshold branch removed.
// and references attest.KindNRAS, keeps building. threshold's main branch has
// already dropped this import (the TEE custody code moved to luxfi/tee), and
// threshold/protocols/tfhe is a real package again, so the next threshold tag
// carries both.
//
// Every symbol here is a zero-logic re-export (type aliases preserve type
// identity with the leaf — a value produced by github.com/luxfi/cc/attest is
+2 -4
View File
@@ -472,10 +472,8 @@ func (p *Node) CreateReshareSession(
}
}
// CreateTFHEKeyGenSession and CreateTFHEComputeSession are gated behind the
// `experimental_tfhe` build tag. See pkg/mpc/tfhe_node.go (experimental impl)
// and pkg/mpc/tfhe_node_stub.go (default-build stub returning
// ErrTFHENotImplemented).
// CreateTFHEKeyGenSession and CreateTFHEComputeSession live in
// pkg/mpc/tfhe_node.go.
func contains(slice []string, item string) bool {
for _, s := range slice {
-23
View File
@@ -1,23 +0,0 @@
// Copyright (c) 2026, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
package mpc
import "errors"
// ErrTFHENotImplemented is returned by every TFHE wallet keygen / compute
// entry point in default builds.
//
// The threshold-FHE wallet ceremony in this codebase is wired to
// luxfi/threshold/protocols/tfhe, which is a fail-loud placeholder: every
// party stores the full master key, PartialDecrypt returns an HMAC tag, and
// CombineShares ignores partials and runs single-party decryption. The
// upstream package panics at every entry point unless
// ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 is set, by design.
//
// Real-threshold wiring (luxfi/lattice Shamir/LWE) is tracked as a separate
// multi-week task per lps/LP-137-TFHE-REAL-THRESHOLD-SPEC.md §2.6. Until that
// lands, the wallet keygen / compute path is gated behind the
// `experimental_tfhe` build tag and default production builds reject every
// caller with this error.
var ErrTFHENotImplemented = errors.New("mpc/tfhe: threshold-FHE wallet keygen requires real Shamir/LWE threshold (LP-137 §2.6); placeholder UNSAFE primitive is gated behind the `experimental_tfhe` build tag")
+14 -23
View File
@@ -1,26 +1,19 @@
// Copyright (c) 2024-2026, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
//go:build experimental_tfhe
// Experimental TFHE wallet ceremony wiring on Node. Default builds use
// tfhe_node_stub.go which returns ErrTFHENotImplemented.
// Node entry points for the threshold-FHE wallet ceremony.
package mpc
import (
"fmt"
"github.com/luxfi/fhe"
"github.com/luxfi/mpc/pkg/messaging"
)
// CreateTFHEKeyGenSession creates a threshold FHE key generation session.
// EXPERIMENTAL: backed by the placeholder UNSAFE primitive in
// luxfi/threshold/protocols/tfhe (every party stores the full master key).
// Compile with `-tags experimental_tfhe` to enable; production must wait for
// real Shamir/LWE wiring per LP-137 §2.6.
// CreateTFHEKeyGenSession starts a dealerless threshold-FHE key generation
// across the ready committee, yielding the collective public key and one
// Shamir share per node.
func (p *Node) CreateTFHEKeyGenSession(
walletID string,
threshold int,
@@ -36,35 +29,36 @@ func (p *Node) CreateTFHEKeyGenSession(
}
readyPeerIDs := p.peerRegistry.GetReadyPeersIncludeSelf()
// TFHE uses version 0 for raw party IDs
selfPartyID, allPartyIDs := p.generatePartyIDs(PurposeKeygen, readyPeerIDs, 0)
// Use default FHE parameters (128-bit security)
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
params, paramsLit, err := tfheParams()
if err != nil {
return nil, fmt.Errorf("failed to create FHE parameters: %w", err)
return nil, fmt.Errorf("mpc/tfhe: parameters: %w", err)
}
session := newTFHEKeygenSession(
session, err := newTFHEKeygenSession(
walletID,
p.pubSub,
selfPartyID,
allPartyIDs,
threshold,
params,
paramsLit,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
if err != nil {
return nil, err
}
return session, nil
}
// CreateTFHEComputeSession creates a session for threshold FHE computation.
// EXPERIMENTAL: backed by the placeholder UNSAFE primitive in
// luxfi/threshold/protocols/tfhe.
// CreateTFHEComputeSession starts a session that encrypts under a wallet's
// collective public key and threshold-decrypts. It loads this node's single
// share.
func (p *Node) CreateTFHEComputeSession(
sessionID string,
walletID string,
@@ -72,12 +66,10 @@ func (p *Node) CreateTFHEComputeSession(
resultQueue messaging.MessageQueue,
orgID string,
) (Session, error) {
// Check if this node is in the participant list
if !contains(participantPeerIDs, p.nodeID) {
return nil, ErrNotInParticipantList
}
// Generate party IDs for participants
selfPartyID, participantPartyIDs := p.generatePartyIDs(PurposeKeygen, participantPeerIDs, 0)
session, err := newTFHEComputeSession(
@@ -95,6 +87,5 @@ func (p *Node) CreateTFHEComputeSession(
if err != nil {
return nil, err
}
return session, nil
}
-49
View File
@@ -1,49 +0,0 @@
// Copyright (c) 2026, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
//go:build !experimental_tfhe
// Default-build stub for the threshold-FHE wallet ceremony. The real
// implementation lives in tfhe_node.go behind the `experimental_tfhe` build
// tag. Production builds reject every caller with ErrTFHENotImplemented
// because the upstream luxfi/threshold/protocols/tfhe primitive is a
// fail-loud placeholder (every party stores the full master key — see
// lps/LP-137-TFHE-REAL-THRESHOLD-SPEC.md §2.6).
package mpc
import "github.com/luxfi/mpc/pkg/messaging"
// CreateTFHEKeyGenSession returns ErrTFHENotImplemented in default builds.
// Compile with `-tags experimental_tfhe` to opt into the placeholder UNSAFE
// primitive (which itself requires ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 at
// runtime to avoid panicking — by design).
func (p *Node) CreateTFHEKeyGenSession(
walletID string,
threshold int,
resultQueue messaging.MessageQueue,
orgID string,
) (Session, error) {
_ = walletID
_ = threshold
_ = resultQueue
_ = orgID
return nil, ErrTFHENotImplemented
}
// CreateTFHEComputeSession returns ErrTFHENotImplemented in default builds.
// See CreateTFHEKeyGenSession for the rationale.
func (p *Node) CreateTFHEComputeSession(
sessionID string,
walletID string,
participantPeerIDs []string,
resultQueue messaging.MessageQueue,
orgID string,
) (Session, error) {
_ = sessionID
_ = walletID
_ = participantPeerIDs
_ = resultQueue
_ = orgID
return nil, ErrTFHENotImplemented
}
+585 -277
View File
File diff suppressed because it is too large Load Diff
+504
View File
@@ -0,0 +1,504 @@
// Copyright (c) 2026, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
// Tests for the ceremony's node-agreement logic: the common reference seed and
// the position assignment, which every node must compute the same way from
// local information, and the binding of a claimed position to its sender.
package mpc
import (
"encoding/hex"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/multiparty"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/tfhe"
)
var ceremonyNoise = []byte("lux-mpc-tfhe-session-test-noise-seed-v1")
// TestTFHECRSSeed_KnownAnswer pins the ceremony's common reference seed.
func TestTFHECRSSeed_KnownAnswer(t *testing.T) {
ids := []party.ID{"node-a", "node-b", "node-c"}
const want = "cf972f3e60fd2cee2b1faf52bb4a0d1d55e7835c29e753b3cca70c708769909f"
got := hex.EncodeToString(tfheCRSSeed("wallet-1", ids))
if got != want {
t.Errorf("tfheCRSSeed = %s, want %s", got, want)
}
}
// TestTFHECRSSeed_PermutationInvariant checks the seed is independent of the
// order peers were discovered in.
func TestTFHECRSSeed_PermutationInvariant(t *testing.T) {
discovered := [][]party.ID{
{"node-a", "node-b", "node-c"},
{"node-c", "node-a", "node-b"},
{"node-b", "node-c", "node-a"},
}
var first string
for i, ids := range discovered {
ordered, _ := tfhePositions(ids)
seed := hex.EncodeToString(tfheCRSSeed("wallet-1", ordered))
if i == 0 {
first = seed
continue
}
if seed != first {
t.Fatalf("discovery order %v derived seed %s, want %s", ids, seed, first)
}
}
}
// TestTFHECRSSeed_BoundToWalletAndCommittee checks the seed is not reused
// across wallets or committees.
func TestTFHECRSSeed_BoundToWalletAndCommittee(t *testing.T) {
base := []party.ID{"node-a", "node-b", "node-c"}
seed := hex.EncodeToString(tfheCRSSeed("wallet-1", base))
if other := hex.EncodeToString(tfheCRSSeed("wallet-2", base)); other == seed {
t.Error("two wallets derived the same reference seed")
}
if other := hex.EncodeToString(tfheCRSSeed("wallet-1", append(base, "node-d"))); other == seed {
t.Error("two committees derived the same reference seed")
}
// The separator makes the committee list unambiguous: "ab","c" and
// "a","bc" must not collide.
split := hex.EncodeToString(tfheCRSSeed("w", []party.ID{"ab", "c"}))
if other := hex.EncodeToString(tfheCRSSeed("w", []party.ID{"a", "bc"})); other == split {
t.Error("committee members are concatenated ambiguously")
}
}
// TestTFHEPositions_Canonical checks positions are 1-based, dense, and
// independent of arrival order.
func TestTFHEPositions_Canonical(t *testing.T) {
ordered, at := tfhePositions([]party.ID{"node-c", "node-a", "node-b"})
want := []party.ID{"node-a", "node-b", "node-c"}
if !reflect.DeepEqual(ordered, want) {
t.Fatalf("order = %v, want %v", ordered, want)
}
for i, id := range want {
if at[id] != i+1 {
t.Errorf("%s at position %d, want %d", id, at[id], i+1)
}
}
if _, ok := at["node-z"]; ok {
t.Error("a non-member was assigned a position")
}
}
// TestTFHECeremony_NodesAgreeOnCollectiveKey runs both rounds on three
// separate members and checks they assemble a byte-identical collective public
// key whose shares threshold-decrypt. Each member holds only its own
// contribution.
func TestTFHECeremony_NodesAgreeOnCollectiveKey(t *testing.T) {
params, lit, err := tfheParams()
if err != nil {
t.Fatalf("parameters: %v", err)
}
const (
walletID = "wallet-ceremony"
threshold = 2
total = 3
)
ordered, at := tfhePositions([]party.ID{"node-c", "node-a", "node-b"})
seed := tfheCRSSeed(walletID, ordered)
// Each node derives the reference polynomial and samples its own
// contribution.
type node struct {
position int
crp multiparty.PublicKeyGenCRP
ckg multiparty.PublicKeyGenProtocol
dealer *tfhe.Party
}
nodes := make([]*node, total)
for _, id := range ordered {
position := at[id]
crp, ckg, err := tfhe.Reference(params, seed)
if err != nil {
t.Fatalf("reference polynomial at position %d: %v", position, err)
}
dealer, err := tfhe.NewParty(position, threshold, total, params)
if err != nil {
t.Fatalf("member at position %d: %v", position, err)
}
nodes[position-1] = &node{position: position, crp: crp, ckg: ckg, dealer: dealer}
}
// Round 1, over the wire encodings the session uses.
public := make([]tfhe.Public, 0, total)
inbox := make([][]tfhe.Point, total)
for _, n := range nodes {
publicShare, subShares, err := n.dealer.Deal(n.crp)
if err != nil {
t.Fatalf("deal at position %d: %v", n.position, err)
}
body, err := publicShare.Share.MarshalBinary()
if err != nil {
t.Fatalf("encode collective-key share at position %d: %v", n.position, err)
}
wire, err := json.Marshal(tfhePublicShare{From: n.position, Share: body})
if err != nil {
t.Fatalf("encode broadcast at position %d: %v", n.position, err)
}
var decodedPublic tfhePublicShare
if err := json.Unmarshal(wire, &decodedPublic); err != nil {
t.Fatalf("decode broadcast at position %d: %v", n.position, err)
}
share := nodes[0].ckg.AllocateShare()
if err := share.UnmarshalBinary(decodedPublic.Share); err != nil {
t.Fatalf("decode collective-key share at position %d: %v", n.position, err)
}
public = append(public, tfhe.Public{From: decodedPublic.From, Share: share})
for _, sub := range subShares {
wire, err := json.Marshal(tfheSubShare{From: sub.From, To: sub.To, Coeffs: sub.Coeffs})
if err != nil {
t.Fatalf("encode sub-share %d to %d: %v", sub.From, sub.To, err)
}
var decoded tfheSubShare
if err := json.Unmarshal(wire, &decoded); err != nil {
t.Fatalf("decode sub-share %d to %d: %v", sub.From, sub.To, err)
}
inbox[decoded.To-1] = append(inbox[decoded.To-1], tfhe.Point{
From: decoded.From,
To: decoded.To,
Coeffs: decoded.Coeffs,
})
}
}
// Round 2: assemble the public key, fold the inbox into a share.
var firstKey []byte
members := make([]*tfhe.Member, total)
for _, n := range nodes {
pub, err := tfhe.Assemble(n.ckg, n.crp, public, params)
if err != nil {
t.Fatalf("collective public key at position %d: %v", n.position, err)
}
body, err := pub.MarshalBinary()
if err != nil {
t.Fatalf("encode collective public key at position %d: %v", n.position, err)
}
if n.position == 1 {
firstKey = body
} else if !reflect.DeepEqual(body, firstKey) {
t.Fatalf("position %d assembled a different collective public key", n.position)
}
share, err := n.dealer.Aggregate(inbox[n.position-1])
if err != nil {
t.Fatalf("fold sub-shares at position %d: %v", n.position, err)
}
n.dealer.Zeroize()
member := &tfhe.Member{
Params: lit,
Threshold: threshold,
Total: total,
Key: body,
Share: share,
}
if err := member.Validate(); err != nil {
t.Fatalf("member at position %d: %v", n.position, err)
}
members[n.position-1] = member
}
// Encrypt under the collective key, decrypt with a threshold of shares.
pub, err := members[0].Collective()
if err != nil {
t.Fatalf("collective public key: %v", err)
}
const want = uint64(0x2A)
ciphertext, err := tfhe.Encrypt(params, pub, want, fhe.FheUint8)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
shares := make([]tfhe.Decryption, 0, threshold)
for _, m := range members[total-threshold:] {
prng, err := sampling.NewKeyedPRNG(append(append([]byte(nil), ceremonyNoise...), byte(m.Share.Index)))
if err != nil {
t.Fatalf("keyed prng: %v", err)
}
s, err := m.Decrypt(ciphertext, prng)
if err != nil {
t.Fatalf("partial decryption at position %d: %v", m.Share.Index, err)
}
shares = append(shares, s)
}
bits, err := tfhe.Decrypt(ciphertext, shares, params, threshold)
if err != nil {
t.Fatalf("combine: %v", err)
}
if got := tfhe.Value(bits); got != want {
t.Fatalf("committee recovered %#x, want %#x", got, want)
}
}
// TestTFHECeremony_ReferencePolynomialFollowsTheSeed checks the reference
// polynomial is a function of the seed alone.
func TestTFHECeremony_ReferencePolynomialFollowsTheSeed(t *testing.T) {
params, _, err := tfheParams()
if err != nil {
t.Fatalf("parameters: %v", err)
}
ordered, _ := tfhePositions([]party.ID{"node-b", "node-a"})
reference := func(walletID string, ids []party.ID) multiparty.PublicKeyGenCRP {
t.Helper()
crp, _, err := tfhe.Reference(params, tfheCRSSeed(walletID, ids))
if err != nil {
t.Fatalf("reference polynomial: %v", err)
}
return crp
}
mine := reference("wallet-1", ordered)
yours := reference("wallet-1", ordered)
if !mine.Value.Q.Equal(&yours.Value.Q) {
t.Fatal("two nodes derived different reference polynomials for one ceremony")
}
otherWallet := reference("wallet-2", ordered)
if mine.Value.Q.Equal(&otherWallet.Value.Q) {
t.Fatal("two wallets share a reference polynomial")
}
otherCommittee := reference("wallet-1", append(ordered, "node-c"))
if mine.Value.Q.Equal(&otherCommittee.Value.Q) {
t.Fatal("two committees share a reference polynomial")
}
}
// TestTFHEWireTypesCarryNoSecretKey checks neither wire type has a field that
// could carry a whole secret key.
func TestTFHEWireTypesCarryNoSecretKey(t *testing.T) {
forbidden := []string{"SecretKey", "SKLWE", "SKBR", "MasterKey"}
for _, v := range []interface{}{tfhePublicShare{}, tfheSubShare{}} {
ty := reflect.TypeOf(v)
for i := 0; i < ty.NumField(); i++ {
f := ty.Field(i)
field := f.Name + ":" + f.Type.String()
for _, bad := range forbidden {
if strings.Contains(field, bad) {
t.Fatalf("%s field %q carries secret-key material (%q)", ty.Name(), field, bad)
}
}
}
}
}
// TestTFHEWalletKey_KnownAnswer pins the key-store name.
func TestTFHEWalletKey_KnownAnswer(t *testing.T) {
if got, want := tfheWalletKey("wallet-1"), "tfhe:wallet-1"; got != want {
t.Errorf("tfheWalletKey = %q, want %q", got, want)
}
}
// TestNoWholeKeyInThresholdFHESource fails if this lane's source calls a
// whole-key generator, the trusted-dealer split, a single-party decryptor or a
// secret-key encryptor. The scheme's own source is gated the same way.
func TestNoWholeKeyInThresholdFHESource(t *testing.T) {
forbiddenCalls := map[string]struct{}{
"ShareLWESecretKey": {},
"ShareLWESecretKeyFHE": {},
"GenKeyPair": {},
"GenSecretKey": {},
"GenSecretKeyNew": {},
"NewDecryptor": {},
"NewBitwiseDecryptor": {},
"NewEncryptor": {},
"NewBitwiseEncryptor": {},
"CommitKey": {},
"DecryptBitFromPlaintext": {},
}
forbiddenNames := regexp.MustCompile(`(?i)reconstruct|recoversecret|combinesecret|recovermaster`)
lane := []string{"tfhe*.go", "../policy/fhe_threshold*.go"}
scanned := 0
for _, pattern := range lane {
matched, err := filepath.Glob(pattern)
if err != nil {
t.Fatalf("glob %s: %v", pattern, err)
}
for _, path := range matched {
if strings.HasSuffix(path, "_test.go") {
continue
}
src, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
scanned++
if bad := forbiddenCall(src, forbiddenCalls, forbiddenNames); bad != "" {
t.Errorf("%s calls %q, which would form a whole FHE key", path, bad)
}
}
}
if scanned == 0 {
t.Fatal("scanned no source files")
}
}
// TestNoWholeKeyGateHasTeeth checks the gate above catches a violation.
func TestNoWholeKeyGateHasTeeth(t *testing.T) {
src := `package x
func victim() {
sk := whole()
ShareLWESecretKey(sk, params, 2, 3)
}`
f, err := parser.ParseFile(token.NewFileSet(), "synthetic.go", src, 0)
if err != nil {
t.Fatalf("parse synthetic: %v", err)
}
calls := map[string]struct{}{"ShareLWESecretKey": {}}
if bad := forbiddenCall(f, calls, regexp.MustCompile(`$^`)); bad == "" {
t.Fatal("the gate missed an injected whole-key call")
}
}
// forbiddenCall returns the first forbidden call target in f, or "".
func forbiddenCall(f *ast.File, calls map[string]struct{}, names *regexp.Regexp) string {
found := ""
ast.Inspect(f, 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 := calls[name]; bad {
found = name
return false
}
if names.MatchString(name) {
found = name
return false
}
return true
})
return found
}
// TestTFHEPositionsByNode_KnownAnswer pins the sender-to-position map.
func TestTFHEPositionsByNode_KnownAnswer(t *testing.T) {
ordered, _ := tfhePositions([]party.ID{"node-c", "node-a", "node-b"})
byNode := tfhePositionsByNode(ordered)
for node, want := range map[string]int{"node-a": 1, "node-b": 2, "node-c": 3} {
if byNode[node] != want {
t.Errorf("%s maps to position %d, want %d", node, byNode[node], want)
}
}
if _, ok := byNode["node-z"]; ok {
t.Error("a node outside the committee has a position")
}
}
// TestTFHEKeygen_RefusesAClaimedPosition checks a message may only speak for
// the position of the member that sent it.
func TestTFHEKeygen_RefusesAClaimedPosition(t *testing.T) {
params, lit, err := tfheParams()
if err != nil {
t.Fatalf("parameters: %v", err)
}
ids := []party.ID{"node-a", "node-b", "node-c"}
s, err := newTFHEKeygenSession("wallet-binding", nil, "node-a", ids, 2, params, lit,
nil, nil, nil, nil, "org")
if err != nil {
t.Fatalf("session: %v", err)
}
defer s.Stop()
if s.position != 1 {
t.Fatalf("this node is at position %d, want 1", s.position)
}
n := params.ParamsLWE().RingQ().N()
coeffs := make([]uint64, n)
// node-b (position 2) dealing a sub-share in node-c's name.
body, err := json.Marshal(tfheSubShare{From: 3, To: 1, Coeffs: coeffs})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := s.acceptSubShare(2, body); err == nil {
t.Fatal("a sub-share claiming another member's position was accepted")
} else if !strings.Contains(err.Error(), "claiming position 3") {
t.Fatalf("refused for the wrong reason: %v", err)
}
// The same member dealing for itself is accepted.
body, err = json.Marshal(tfheSubShare{From: 2, To: 1, Coeffs: coeffs})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := s.acceptSubShare(2, body); err != nil {
t.Fatalf("an honest sub-share was refused: %v", err)
}
// And the same binding on the broadcast side.
share := s.ckg.AllocateShare()
encoded, err := share.MarshalBinary()
if err != nil {
t.Fatalf("encode collective-key share: %v", err)
}
body, err = json.Marshal(tfhePublicShare{From: 3, Share: encoded})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := s.acceptPublicShare(2, body); err == nil {
t.Fatal("a collective-key share claiming another member's position was accepted")
} else if !strings.Contains(err.Error(), "claiming position 3") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
// TestTFHEKeygen_RefusesAMisaddressedSubShare checks a sub-share is accepted
// only by the member it is addressed to.
func TestTFHEKeygen_RefusesAMisaddressedSubShare(t *testing.T) {
params, lit, err := tfheParams()
if err != nil {
t.Fatalf("parameters: %v", err)
}
s, err := newTFHEKeygenSession("wallet-addressing", nil, "node-a",
[]party.ID{"node-a", "node-b", "node-c"}, 2, params, lit, nil, nil, nil, nil, "org")
if err != nil {
t.Fatalf("session: %v", err)
}
defer s.Stop()
coeffs := make([]uint64, params.ParamsLWE().RingQ().N())
body, err := json.Marshal(tfheSubShare{From: 2, To: 3, Coeffs: coeffs})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := s.acceptSubShare(2, body); err == nil {
t.Fatal("a sub-share addressed to another member was accepted")
} else if !strings.Contains(err.Error(), "addressed to position 3") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
+81 -197
View File
@@ -1,255 +1,139 @@
//go:build experimental_tfhe
// RealThresholdDecryptor wires the FHEVerifier's ThresholdDecryptor
// interface to the t-of-n committee implemented in
// luxfi/threshold/protocols/tfhe.
//
// EXPERIMENTAL — gated behind the `experimental_tfhe` build tag because
// the upstream luxfi/threshold/protocols/tfhe primitive is a fail-loud
// placeholder that is NOT a real threshold scheme: every party stores
// the full master key, PartialDecrypt returns an HMAC tag, and
// CombineShares ignores partials and runs single-party decryption (it
// panics at every entry point unless ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1,
// by design). That package was deleted from luxfi/threshold upstream
// (v1.10.0+); this file builds only against the older v1.9.x line and
// only verifies dispatcher fan-out + aggregator wiring shape — it proves
// NO threshold-security property. Default production builds compile
// fhe_threshold_decryptor_stub.go instead, which fails CLOSED with
// ErrThresholdFHENotWired. Real-threshold wiring (luxfi/fhe pkg/threshold
// Shamir/LWE: ShareLWESecretKey / PartialDecryptLWE / CombineLWE) is
// tracked per LP-137 §2.6.
//
// FHECiphertext / FHEThresholdShare / KeyShare / ShareAggregator.
//
// Flow:
//
// 1. Send a PartialDecrypt request to every configured PartyClient.
// 2. Collect FHEThresholdShare responses as they arrive.
// 3. Once `threshold` valid shares are in hand, call
// ShareAggregateService.Aggregate.
// 4. Round the recovered plaintext to a boolean (FHE policy verdicts
// are 1-bit) and return.
//
// Failure modes are mapped to ErrFHEThresholdDec by the verifier wrapper
// so the policy gate fails closed on any committee anomaly.
//
// This file is additive — it does not modify the FHEVerifier struct or
// its existing constructors. Callers wire RealThresholdDecryptor via
// FHEVerifier.Decryptor like any other ThresholdDecryptor.
//
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Committee is the FHE policy gate's threshold decryptor, backed by the
// M-Chain committee of github.com/luxfi/threshold/protocols/tfhe.
//
// One encrypted verdict bit goes to every member; each returns a partial
// decryption under its own share, bound by digest to the ciphertext. Any
// failure returns an error, which FHEVerifier reads as a denial.
package policy
import (
"context"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"sync"
fhethr "github.com/luxfi/threshold/protocols/tfhe"
"github.com/luxfi/fhe"
"github.com/luxfi/threshold/protocols/tfhe"
)
// ErrCommitteeQuorum is returned by RealThresholdDecryptor.Decrypt when
// the configured threshold could not be assembled from the available
// PartyClient set within the request lifetime.
var ErrCommitteeQuorum = errors.New("real-threshold-decryptor: insufficient quorum")
// PartyClient is the per-peer RPC client. The default deployment maps
// one PartyClient to one M-Chain MPC node hosting a single
// FHEThresholdShare-producing endpoint.
//
// Implementations are responsible for transport (TLS 1.3, ZAP wire
// types 60-79 per LP-022, etc.); this interface only describes the
// shape of the request/response.
// PartyClient is the per-member RPC client, one per node holding one share.
// Implementations own the transport.
type PartyClient interface {
// PartyID returns the 1-indexed party identifier this client
// targets. Used by the dispatcher to demux responses and by the
// aggregator to verify per-party material.
PartyID() uint32
// Party returns the member's 1-based committee position.
Party() int
// PartialDecrypt issues a partial-decrypt request to the peer.
// Implementations are expected to be concurrent-safe.
PartialDecrypt(
ctx context.Context,
ciphertext fhethr.FHECiphertext,
sessionID [32]byte,
) (fhethr.FHEThresholdShare, error)
// PartialDecrypt asks the member to partially decrypt ciphertext.
// Implementations must be safe for concurrent use.
PartialDecrypt(ctx context.Context, ciphertext []byte) (tfhe.Decryption, error)
}
// RealThresholdDecryptor is the production ThresholdDecryptor wired to
// the M-Chain MPC committee.
type RealThresholdDecryptor struct {
// Aggregate is the local aggregation service. Defaults to a
// fhethr.NewShareAggregator() when nil.
Aggregate fhethr.ShareAggregateService
// Parties is the per-party RPC client set. The dispatcher fans out
// to every entry in parallel and short-circuits as soon as
// `threshold` valid shares are received.
// Committee is the threshold-FHE decryptor for the policy gate.
type Committee struct {
// Parties is the member client set. Requests fan out to all of them.
Parties []PartyClient
// Threshold is the t-of-n bound. Must satisfy 1 ≤ Threshold ≤
// len(Parties).
Threshold uint32
// Threshold is how many partial decryptions recover a verdict.
Threshold int
// PartyKeys, when set, switches the aggregator to the symmetric-key
// verification path (see fhethr.ShareAggregator). Production
// committee self-checks populate this field; cross-committee
// callers leave it nil and rely on the public-key path once CDS
// noise proofs ship.
PartyKeys map[uint32]fhethr.KeyShare
// Params is the committee's FHE parameter set.
Params fhe.Parameters
}
// NewRealThresholdDecryptor returns a configured decryptor.
func NewRealThresholdDecryptor(
parties []PartyClient,
threshold uint32,
) *RealThresholdDecryptor {
return &RealThresholdDecryptor{
Aggregate: fhethr.NewShareAggregator(),
Parties: parties,
Threshold: threshold,
}
// NewThresholdDecryptor returns the policy gate's threshold decryptor.
func NewThresholdDecryptor(parties []PartyClient, threshold int, params fhe.Parameters) *Committee {
return &Committee{Parties: parties, Threshold: threshold, Params: params}
}
// Decrypt implements ThresholdDecryptor. It accepts the opaque
// encrypted-verdict bytes, fans out partial-decrypt requests to the
// committee, and aggregates ≥t shares into a boolean verdict.
//
// The sessionID is derived from sha256("decrypt" || ciphertext) — the
// FHEVerifier issues one ciphertext per (intent, policy) pair so the
// derivation is replay-safe across calls. Production may layer a
// caller-supplied sessionID on top via decorating the ciphertext bytes.
func (d *RealThresholdDecryptor) Decrypt(
ctx context.Context,
ciphertextBytes []byte,
) (bool, error) {
if d.Threshold == 0 {
return false, fmt.Errorf("real-threshold-decryptor: threshold must be > 0")
// Decrypt implements ThresholdDecryptor: the encrypted verdict is a one-bit
// ciphertext and Decrypt returns that bit. A wider ciphertext is refused.
func (c *Committee) Decrypt(ctx context.Context, ciphertext []byte) (bool, error) {
if c.Threshold < 1 {
return false, fmt.Errorf("policy/tfhe: threshold must be at least 1, got %d", c.Threshold)
}
if uint32(len(d.Parties)) < d.Threshold {
return false, ErrCommitteeQuorum
if len(c.Parties) < c.Threshold {
return false, fmt.Errorf("%w: committee has %d members, threshold is %d",
tfhe.ErrQuorum, len(c.Parties), c.Threshold)
}
ct := fhethr.NewFHECiphertext(ciphertextBytes)
sess := sessionForCiphertext(ct)
shares, err := d.collectShares(ctx, ct, sess)
verdict, err := tfhe.Parse(ciphertext)
if err != nil {
return false, err
}
if uint32(len(shares)) < d.Threshold {
return false, ErrCommitteeQuorum
if verdict.NumBits() != 1 {
return false, fmt.Errorf("policy/tfhe: encrypted verdict carries %d bits, want 1", verdict.NumBits())
}
agg := d.Aggregate
if agg == nil {
agg = fhethr.NewShareAggregator()
}
if a, ok := agg.(*fhethr.ShareAggregator); ok && d.PartyKeys != nil {
a.PartyKeys = d.PartyKeys
}
res, plaintext, err := agg.Aggregate(ctx, ct, shares, d.Threshold, sess)
shares, err := c.collect(ctx, ciphertext)
if err != nil {
return false, fmt.Errorf("real-threshold-decryptor: aggregate: %w", err)
return false, err
}
if res.Status != fhethr.StatusOK {
return false, fmt.Errorf("real-threshold-decryptor: status=%s", res.Status)
bits, err := tfhe.Decrypt(ciphertext, shares, c.Params, c.Threshold)
if err != nil {
return false, err
}
return roundToBool(plaintext), nil
return bits[0], nil
}
// collectShares fans out PartialDecrypt to every configured peer and
// returns as soon as `threshold` shares are collected. Errors from
// individual peers are accumulated for diagnostic but not propagated:
// the protocol succeeds when ≥t valid shares arrive regardless of how
// many peers reported errors.
func (d *RealThresholdDecryptor) collectShares(
ctx context.Context,
ct fhethr.FHECiphertext,
sess [32]byte,
) ([]fhethr.FHEThresholdShare, error) {
type result struct {
share fhethr.FHEThresholdShare
// collect fans out to every member and returns as soon as the contributions in
// hand contain a quorum that recombines exactly; reaching the threshold count
// alone is not sufficient (tfhe.Quorum).
//
// A member that errors, repeats a position, or answers about a different
// ciphertext does not occupy a quorum slot.
func (c *Committee) collect(ctx context.Context, ciphertext []byte) ([]tfhe.Decryption, error) {
want := tfhe.Digest(ciphertext)
type answer struct {
share tfhe.Decryption
err error
}
out := make(chan result, len(d.Parties))
out := make(chan answer, len(c.Parties))
subCtx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
for _, p := range d.Parties {
for _, p := range c.Parties {
wg.Add(1)
go func(p PartyClient) {
defer wg.Done()
s, err := p.PartialDecrypt(subCtx, ct, sess)
share, err := p.PartialDecrypt(subCtx, ciphertext)
if err == nil && share.From != p.Party() {
err = fmt.Errorf("member %d answered as %d", p.Party(), share.From)
}
select {
case out <- result{share: s, err: err}:
case out <- answer{share: share, err: err}:
case <-subCtx.Done():
}
}(p)
}
go func() { wg.Wait(); close(out) }()
collected := make([]fhethr.FHEThresholdShare, 0, d.Threshold)
seen := make(map[uint32]struct{}, d.Threshold)
var errs []error
for r := range out {
if r.err != nil {
errs = append(errs, r.err)
collected := make([]tfhe.Decryption, 0, len(c.Parties))
positions := make([]int, 0, len(c.Parties))
seen := make(map[int]struct{}, len(c.Parties))
rejected := 0
for a := range out {
if a.err != nil {
rejected++
continue
}
if _, dup := seen[r.share.PartyID]; dup {
if a.share.Digest != want {
rejected++
continue
}
seen[r.share.PartyID] = struct{}{}
collected = append(collected, r.share)
if uint32(len(collected)) >= d.Threshold {
cancel()
if _, dup := seen[a.share.From]; dup {
rejected++
continue
}
seen[a.share.From] = struct{}{}
collected = append(collected, a.share)
positions = append(positions, a.share.From)
if tfhe.Quorum(positions, c.Threshold) {
return collected, nil
}
}
if uint32(len(collected)) < d.Threshold {
return collected, fmt.Errorf("%w (collected %d, errors %d)", ErrCommitteeQuorum, len(collected), len(errs))
}
return collected, nil
}
// sessionForCiphertext derives a deterministic 32-byte sessionID from
// the ciphertext bytes. Per-call uniqueness comes from the F-Chain
// PolicyVault: each evaluation produces a distinct ciphertext, so each
// produces a distinct session.
//
// An additional 16 bytes of crypto/rand entropy are mixed in to defend
// against the (small) chance of two evaluations producing identical
// ciphertext bytes due to deterministic FHE encoding — at the cost of
// the per-call replay protection in the producer's PartialDecrypter
// being a no-op. Production deployments anchoring the session to
// ciphertext-only MUST disable the entropy mix.
func sessionForCiphertext(ct fhethr.FHECiphertext) [32]byte {
var rnd [16]byte
_, _ = rand.Read(rnd[:])
h := sha256.New()
h.Write([]byte("LUX/FHE/THRESHOLD/SESSION/v1"))
h.Write(ct.ID[:])
h.Write(rnd[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// roundToBool reduces an arbitrary plaintext byte slice to a single
// boolean: true iff any byte is non-zero. The FHE policy circuit emits
// a 1-bit verdict, so the plaintext is always 1 byte in production —
// the byte-vector path here matches the toy threshold scheme used by
// the threshold package's unit tests.
func roundToBool(plaintext []byte) bool {
for _, b := range plaintext {
if b != 0 {
return true
}
}
return false
return nil, fmt.Errorf("%w: %d of %d members answered, %d unusable, and no %d of them recombine exactly",
tfhe.ErrQuorum, len(collected), len(c.Parties), rejected, c.Threshold)
}
@@ -1,58 +0,0 @@
//go:build !experimental_tfhe
// Default (production) build of the threshold-FHE policy decryptor.
//
// The encrypted-verdict committee decryptor is NOT wired to a real
// threshold scheme yet. The only existing committee implementation,
// luxfi/threshold/protocols/tfhe, is a fail-loud placeholder where every
// party holds the full master key (PartialDecrypt returns an HMAC tag and
// CombineShares runs single-party decryption) — it provides NO threshold
// security and was removed from luxfi/threshold upstream. That placeholder
// is reachable only under the `experimental_tfhe` build tag
// (fhe_threshold_decryptor.go); it is intentionally excluded from
// production builds.
//
// Real threshold-FHE decryption is to be wired onto luxfi/fhe pkg/threshold
// (ShareLWESecretKey / PartialDecryptLWE / CombineLWE — genuine Shamir/LWE
// partial decryption with smudging noise and Lagrange combine) plus the
// committee transport/session layer. That is an open task per LP-137 §2.6.
//
// Until it lands, production fails CLOSED: NewThresholdDecryptor returns a
// decryptor whose Decrypt always reports ErrThresholdFHENotWired, which
// FHEVerifier maps to ErrFHEThresholdDec — i.e. a policy deny. This is the
// only safe posture when the committee cannot actually decrypt a verdict.
//
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package policy
import (
"context"
"errors"
)
// ErrThresholdFHENotWired is returned by the production ThresholdDecryptor
// because real threshold-FHE committee decryption is not yet wired. Build
// with `-tags experimental_tfhe` to compile the UNSAFE placeholder instead
// (see fhe_threshold_decryptor.go).
var ErrThresholdFHENotWired = errors.New(
"policy: threshold FHE decryption not yet wired — real Shamir/LWE committee " +
"(luxfi/fhe pkg/threshold) pending LP-137 §2.6; production fails closed",
)
// UnwiredThresholdDecryptor is the default-build ThresholdDecryptor. It
// satisfies the ThresholdDecryptor interface and fails closed on every
// call, so an FHEVerifier configured with it denies any FHE-gated intent
// rather than trusting an un-decryptable verdict.
type UnwiredThresholdDecryptor struct{}
// NewThresholdDecryptor returns the production ThresholdDecryptor. In the
// default build this is the fail-closed UnwiredThresholdDecryptor; the real
// committee implementation is wired here once LP-137 §2.6 lands.
func NewThresholdDecryptor() ThresholdDecryptor { return UnwiredThresholdDecryptor{} }
// Decrypt always returns ErrThresholdFHENotWired (fail-closed). It never
// returns true: there is no real committee to decrypt the verdict.
func (UnwiredThresholdDecryptor) Decrypt(_ context.Context, _ []byte) (bool, error) {
return false, ErrThresholdFHENotWired
}
+256 -121
View File
@@ -1,162 +1,297 @@
//go:build experimental_tfhe
// Tests for RealThresholdDecryptor. The PartyClient interface is
// stubbed out with a thin in-memory adapter that wraps the
// fhethr.PartialDecrypter directly — exercising the dispatcher fan-out
// + aggregator wiring without an actual RPC layer.
//
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Tests for the threshold-FHE policy decryptor, over a real dealerless
// committee served in-process. The smudging noise is seeded, so a run is
// reproducible; the committee key is freshly generated.
package policy
import (
"context"
"errors"
"strings"
"testing"
fhethr "github.com/luxfi/threshold/protocols/tfhe"
"github.com/luxfi/fhe"
"github.com/luxfi/lattice/v7/utils/sampling"
"github.com/luxfi/threshold/protocols/tfhe"
)
// stubParty implements PartyClient over a local PartialDecrypter +
// fixed KeyShare. Used by tests only.
type stubParty struct {
id uint32
key fhethr.KeyShare
decrypt *fhethr.PartialDecrypter
err error // injected error, when set is returned instead of decrypting
var (
committeeCRSSeed = []byte("lux-mpc-policy-committee-test-crs-seed-v1")
committeeNoise = []byte("lux-mpc-policy-committee-test-noise-seed-v1")
)
// member serves one committee member over the PartyClient interface.
type member struct {
m *tfhe.Member
// err, when set, is returned instead of a partial decryption.
err error
// answerAbout, when set, is the ciphertext this member decrypts whatever it
// was asked about.
answerAbout []byte
// claimPosition, when non-zero, is the position this member claims to be.
claimPosition int
}
func newStubParty(id uint32, key fhethr.KeyShare) *stubParty {
return &stubParty{
id: id,
key: key,
decrypt: fhethr.NewPartialDecrypter(),
func (p *member) Party() int { return p.m.Share.Index }
func (p *member) PartialDecrypt(_ context.Context, ciphertext []byte) (tfhe.Decryption, error) {
if p.err != nil {
return tfhe.Decryption{}, p.err
}
}
func (s *stubParty) PartyID() uint32 { return s.id }
func (s *stubParty) PartialDecrypt(
ctx context.Context,
ct fhethr.FHECiphertext,
sess [32]byte,
) (fhethr.FHEThresholdShare, error) {
if s.err != nil {
return fhethr.FHEThresholdShare{}, s.err
subject := ciphertext
if p.answerAbout != nil {
subject = p.answerAbout
}
return s.decrypt.PartialDecrypt(ctx, s.key, ct, sess)
prng, err := sampling.NewKeyedPRNG(append(append([]byte(nil), committeeNoise...), byte(p.m.Share.Index)))
if err != nil {
return tfhe.Decryption{}, err
}
share, err := p.m.Decrypt(subject, prng)
if err != nil {
return tfhe.Decryption{}, err
}
if p.claimPosition != 0 {
share.From = p.claimPosition
}
return share, nil
}
func makeTestCommittee(n uint32) ([]PartyClient, map[uint32]fhethr.KeyShare) {
parties := make([]PartyClient, 0, n)
keys := make(map[uint32]fhethr.KeyShare, n)
for i := uint32(1); i <= n; i++ {
b := make([]byte, 32)
for j := range b {
b[j] = byte(i)*0x33 ^ byte(j)
// newCommittee runs a dealerless key generation and returns the member
// clients, the parameters and the collective public key.
func newCommittee(t *testing.T, threshold, total int) ([]*member, fhe.Parameters, *fhe.PublicKey) {
t.Helper()
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
if err != nil {
t.Fatalf("parameters: %v", err)
}
pub, shares, err := tfhe.Keygen(params, threshold, total, committeeCRSSeed)
if err != nil {
t.Fatalf("dealerless key generation: %v", err)
}
pubBytes, err := pub.MarshalBinary()
if err != nil {
t.Fatalf("encode collective public key: %v", err)
}
members := make([]*member, total)
for i := range shares {
members[i] = &member{m: &tfhe.Member{
Params: fhe.PN10QP27,
Threshold: threshold,
Total: total,
Key: pubBytes,
Share: shares[i],
}}
}
return members, params, pub
}
func clients(members []*member) []PartyClient {
out := make([]PartyClient, len(members))
for i, m := range members {
out[i] = m
}
return out
}
// verdict encrypts a one-bit policy verdict under the collective public key.
func verdict(t *testing.T, params fhe.Parameters, pub *fhe.PublicKey, allow bool) []byte {
t.Helper()
var bit uint64
if allow {
bit = 1
}
ciphertext, err := tfhe.Encrypt(params, pub, bit, fhe.FheBool)
if err != nil {
t.Fatalf("encrypt verdict: %v", err)
}
return ciphertext
}
// TestCommittee_RecoversBothVerdicts asserts both verdict bits through a
// 2-of-3 committee.
func TestCommittee_RecoversBothVerdicts(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
d := NewThresholdDecryptor(clients(members), 2, params)
for _, allow := range []bool{true, false} {
got, err := d.Decrypt(context.Background(), verdict(t, params, pub, allow))
if err != nil {
t.Fatalf("Decrypt(allow=%v): %v", allow, err)
}
if got != allow {
t.Fatalf("Decrypt(allow=%v) = %v", allow, got)
}
k := fhethr.KeyShare{PartyID: i, Bytes: b}
keys[i] = k
parties = append(parties, newStubParty(i, k))
}
return parties, keys
}
func TestRealThresholdDecryptor_2of3(t *testing.T) {
parties, keys := makeTestCommittee(3)
d := NewRealThresholdDecryptor(parties, 2)
d.PartyKeys = keys
// TestCommittee_3of5 covers a larger committee at its threshold.
func TestCommittee_3of5(t *testing.T) {
members, params, pub := newCommittee(t, 3, 5)
d := NewThresholdDecryptor(clients(members), 3, params)
// Plaintext "anything non-zero" → true. The toy threshold scheme
// recovers a non-empty plaintext for any non-empty ciphertext.
ct := []byte("encrypted-policy-verdict-allow")
ok, err := d.Decrypt(context.Background(), ct)
got, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !ok {
t.Fatalf("Decrypt = false, want true (non-zero plaintext)")
if !got {
t.Fatal("Decrypt = false, want true")
}
}
func TestRealThresholdDecryptor_3of5(t *testing.T) {
parties, keys := makeTestCommittee(5)
d := NewRealThresholdDecryptor(parties, 3)
d.PartyKeys = keys
// TestCommittee_ShortCommitteeRefuses checks a committee smaller than its
// threshold is refused before any member is contacted.
func TestCommittee_ShortCommitteeRefuses(t *testing.T) {
members, params, pub := newCommittee(t, 2, 2)
d := NewThresholdDecryptor(clients(members), 3, params)
ct := []byte("encrypted-policy-3of5")
ok, err := d.Decrypt(context.Background(), ct)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !ok {
t.Fatalf("Decrypt = false")
_, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if !errors.Is(err, tfhe.ErrQuorum) {
t.Fatalf("err = %v, want a quorum failure", err)
}
}
func TestRealThresholdDecryptor_InsufficientPeers(t *testing.T) {
parties, _ := makeTestCommittee(2)
d := NewRealThresholdDecryptor(parties, 3) // threshold > peers
_, err := d.Decrypt(context.Background(), []byte("ciphertext"))
if !errors.Is(err, ErrCommitteeQuorum) {
t.Fatalf("err = %v, want ErrCommitteeQuorum", err)
}
}
// TestCommittee_ZeroThresholdRefuses checks a threshold of zero is refused.
func TestCommittee_ZeroThresholdRefuses(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
d := NewThresholdDecryptor(clients(members), 0, params)
func TestRealThresholdDecryptor_PartyErrors(t *testing.T) {
parties, keys := makeTestCommittee(3)
// Two parties err; threshold=2 cannot be met.
parties[0].(*stubParty).err = errors.New("network down")
parties[1].(*stubParty).err = errors.New("network down")
d := NewRealThresholdDecryptor(parties, 2)
d.PartyKeys = keys
_, err := d.Decrypt(context.Background(), []byte("ciphertext"))
if !errors.Is(err, ErrCommitteeQuorum) {
t.Fatalf("err = %v, want ErrCommitteeQuorum", err)
}
}
func TestRealThresholdDecryptor_OnePartyError_SufficientQuorum(t *testing.T) {
parties, keys := makeTestCommittee(3)
// One party errs; threshold=2, two healthy peers can satisfy.
parties[2].(*stubParty).err = errors.New("network down")
d := NewRealThresholdDecryptor(parties, 2)
d.PartyKeys = keys
ok, err := d.Decrypt(context.Background(), []byte("ciphertext"))
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !ok {
t.Fatalf("Decrypt = false")
}
}
func TestRealThresholdDecryptor_ZeroThreshold(t *testing.T) {
parties, _ := makeTestCommittee(3)
d := NewRealThresholdDecryptor(parties, 0)
_, err := d.Decrypt(context.Background(), []byte("ciphertext"))
_, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if err == nil {
t.Fatalf("expected error on zero threshold")
t.Fatal("Decrypt accepted a threshold of zero")
}
if !strings.Contains(err.Error(), "threshold must be at least 1") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
func TestRoundToBool(t *testing.T) {
cases := []struct {
in []byte
want bool
}{
{nil, false},
{[]byte{}, false},
{[]byte{0x00}, false},
{[]byte{0x00, 0x00, 0x00}, false},
{[]byte{0x01}, true},
{[]byte{0x00, 0x01, 0x00}, true},
// TestCommittee_TooManyUnreachableRefuses checks member failures below the
// threshold refuse rather than guess the verdict.
func TestCommittee_TooManyUnreachableRefuses(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
members[0].err = errors.New("network down")
members[1].err = errors.New("network down")
d := NewThresholdDecryptor(clients(members), 2, params)
_, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if !errors.Is(err, tfhe.ErrQuorum) {
t.Fatalf("err = %v, want a quorum failure", err)
}
for _, c := range cases {
if got := roundToBool(c.in); got != c.want {
t.Errorf("roundToBool(%x) = %v, want %v", c.in, got, c.want)
}
// TestCommittee_OneUnreachableStillDecrypts checks the threshold is a floor,
// not unanimity.
func TestCommittee_OneUnreachableStillDecrypts(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
members[2].err = errors.New("network down")
d := NewThresholdDecryptor(clients(members), 2, params)
got, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !got {
t.Fatal("Decrypt = false, want true")
}
}
// TestCommittee_ReplayedPartialDoesNotFillQuorum checks a member answering
// about a different ciphertext is neither combined nor counted, and the honest
// members still carry the round.
func TestCommittee_ReplayedPartialDoesNotFillQuorum(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
stale := verdict(t, params, pub, true)
members[0].answerAbout = stale
d := NewThresholdDecryptor(clients(members), 2, params)
got, err := d.Decrypt(context.Background(), verdict(t, params, pub, false))
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if got {
t.Fatal("a replayed partial decryption turned a deny into an allow")
}
}
// TestCommittee_ReplayedPartialRefusesWhenItIsTheQuorum checks the same with
// no honest member to fall back on: the round fails.
func TestCommittee_ReplayedPartialRefusesWhenItIsTheQuorum(t *testing.T) {
members, params, pub := newCommittee(t, 2, 2)
stale := verdict(t, params, pub, true)
members[0].answerAbout = stale
d := NewThresholdDecryptor(clients(members), 2, params)
_, err := d.Decrypt(context.Background(), verdict(t, params, pub, false))
if !errors.Is(err, tfhe.ErrQuorum) {
t.Fatalf("err = %v, want a quorum failure", err)
}
}
// TestCommittee_ImpostorPositionRejected checks a member answering under
// another member's position is rejected.
func TestCommittee_ImpostorPositionRejected(t *testing.T) {
members, params, pub := newCommittee(t, 2, 2)
members[0].claimPosition = 2
d := NewThresholdDecryptor(clients(members), 2, params)
_, err := d.Decrypt(context.Background(), verdict(t, params, pub, true))
if !errors.Is(err, tfhe.ErrQuorum) {
t.Fatalf("err = %v, want a quorum failure", err)
}
}
// TestCommittee_WideCiphertextRefused checks the one-bit verdict contract.
func TestCommittee_WideCiphertextRefused(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
wide, err := tfhe.Encrypt(params, pub, 1, fhe.FheUint8)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
d := NewThresholdDecryptor(clients(members), 2, params)
if _, err := d.Decrypt(context.Background(), wide); err == nil {
t.Fatal("Decrypt accepted an 8-bit verdict")
} else if !strings.Contains(err.Error(), "carries 8 bits") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
// TestCommittee_GarbageCiphertextRefused covers input that is not a
// ciphertext.
func TestCommittee_GarbageCiphertextRefused(t *testing.T) {
members, params, _ := newCommittee(t, 2, 3)
d := NewThresholdDecryptor(clients(members), 2, params)
for _, in := range [][]byte{nil, {}, []byte("not a ciphertext")} {
if _, err := d.Decrypt(context.Background(), in); err == nil {
t.Fatalf("Decrypt accepted %q as a verdict", in)
}
}
}
// TestCommittee_CancelledContextRefuses checks a cancelled round denies.
func TestCommittee_CancelledContextRefuses(t *testing.T) {
members, params, pub := newCommittee(t, 2, 3)
ciphertext := verdict(t, params, pub, true)
for _, m := range members {
m.err = context.Canceled
}
d := NewThresholdDecryptor(clients(members), 2, params)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := d.Decrypt(ctx, ciphertext); err == nil {
t.Fatal("Decrypt returned a verdict for a cancelled round")
}
}
// TestCommittee_SatisfiesThresholdDecryptor keeps the decryptor wired to the
// interface the policy gate consumes.
func TestCommittee_SatisfiesThresholdDecryptor(t *testing.T) {
var _ ThresholdDecryptor = (*Committee)(nil)
}
-31
View File
@@ -1,31 +0,0 @@
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
//go:build experimental_tfhe
// TestMain opts the policy-package tests that touch
// luxfi/threshold/protocols/tfhe (RealThresholdDecryptor wires its
// PartialDecrypter / ShareAggregator) into the placeholder UNSAFE primitive
// by setting ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 for the duration of the
// test binary. Production paths in luxfi/threshold/protocols/tfhe are
// guarded by guardUnsafe() and panic when this env var is unset (by design).
//
// These tests verify dispatcher fan-out and aggregator wiring shape only —
// they do NOT verify any threshold security property because the
// underlying scheme has none. Real-threshold wiring (luxfi/lattice
// Shamir/LWE) is tracked as a separate multi-week task per
// lps/LP-137-TFHE-REAL-THRESHOLD-SPEC.md §2.6.
package policy
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
if err := os.Setenv("ALLOW_FAKE_TFHE_FOR_TESTING_ONLY", "1"); err != nil {
panic("policy test setup: cannot set ALLOW_FAKE_TFHE_FOR_TESTING_ONLY: " + err.Error())
}
os.Exit(m.Run())
}