mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
+4
-4
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user