sec(mpc): gate TFHE wallet keygen behind experimental_tfhe build tag

The threshold-FHE wallet keygen path (pkg/mpc/tfhe_session.go:154,165
production caller via pkg/mpc/node.go:583) was wired to
luxfi/threshold/protocols/tfhe.NewKeyGenerator + GenerateKeys. Per #197,
that primitive 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. Upstream guards every entry
point with guardUnsafe() that panics unless
LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 is set — by design.

Default production builds must not be able to reach the placeholder
primitive even with the env var set. Real-threshold wiring (luxfi/lattice
Shamir/LWE) is tracked as a multi-week separate task per LP-137 §2.6.

Changes:

- pkg/mpc/tfhe_session.go: //go:build experimental_tfhe — entire
  tfheKeygenSession + tfheComputeSession + TFHESession interface gated
  behind the tag.
- pkg/mpc/tfhe_node.go (new, experimental_tfhe): Node.CreateTFHEKeyGenSession
  + CreateTFHEComputeSession constructing the experimental sessions.
- pkg/mpc/tfhe_node_stub.go (new, !experimental_tfhe): stub Node methods
  that return ErrTFHENotImplemented from every entry point. This is what
  the production binary ships with.
- pkg/mpc/tfhe_errors.go (new, unconditional): ErrTFHENotImplemented
  symbol with a description pointing at LP-137 §2.6.
- pkg/mpc/node.go: removed the unconditional CreateTFHEKeyGenSession +
  CreateTFHEComputeSession definitions and the unused luxfi/fhe import.

Test pass:

  go test ./pkg/mpc/... ./pkg/policy/... -short -count=1 -race -timeout 60s
  ok  github.com/luxfi/mpc/pkg/mpc      2.016s
  ok  github.com/luxfi/mpc/pkg/policy   1.477s
  91 PASS, 0 FAIL (default build, no env var set)

  go build -o /tmp/mpcd_final ./cmd/mpcd/
  /tmp/mpcd_final version  # mpcd version 0.3.3 — no panic, no UNSAFE TFHE reach

  go build -tags experimental_tfhe ./pkg/mpc/...  # opt-in build OK

Note: pkg/policy/main_test.go sets LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1
because RealThresholdDecryptor's dispatcher tests exercise the placeholder
PartialDecrypter — same TestMain pattern threshold/protocols/tfhe uses.
These are orchestration-shape tests; they verify no threshold security
property because the underlying scheme has none.

Companion bumps required for the branch to compile:
- luxfi/threshold v1.6.0 -> v1.6.4 (FHECiphertext / FHEThresholdShare /
  ShareAggregator exports landed in v1.6.x)
- google/go-sev-guest -> go.mod (cc/attest/sev.go direct import)
- pkg/api/server.go: missing approval + tieredWallets fields added to
  satisfy in-tree handler references
This commit is contained in:
2026-04-28 08:46:08 -07:00
parent c6098b226d
commit 03b1da16b9
9 changed files with 241 additions and 84 deletions
+4 -1
View File
@@ -34,7 +34,7 @@ require (
github.com/luxfi/hsm v1.1.2
github.com/luxfi/log v1.4.1
github.com/luxfi/metric v1.5.1
github.com/luxfi/threshold v1.6.0
github.com/luxfi/threshold v1.6.4
github.com/luxfi/zap v0.2.0
github.com/minio/minio-go/v7 v7.0.99
github.com/mitchellh/mapstructure v1.5.0
@@ -111,6 +111,8 @@ require (
github.com/golang/mock v1.6.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-sev-guest v0.14.1 // indirect
github.com/google/logger v1.1.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
@@ -204,6 +206,7 @@ require (
go.temporal.io/sdk v1.41.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
+10 -2
View File
@@ -55,6 +55,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/ansel1/merry v1.8.1/go.mod h1:wJVu1mHEtEUWq5zTTX9RiWjcE+xL8y7BGYl2VTYdP7M=
github.com/ansel1/merry/v2 v2.2.2/go.mod h1:sludwzkWfhZHOF4jSOViv+t2nnu2HNmtsMKzAfwmVB8=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -239,6 +241,8 @@ github.com/google/go-configfs-tsm v0.2.2/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGT
github.com/google/go-sev-guest v0.14.1 h1:j/DXy9jk1qSW/dEV9vDiQnhAVFD1zqnWNVu6p1J0Jgo=
github.com/google/go-sev-guest v0.14.1/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@@ -370,8 +374,8 @@ github.com/luxfi/crypto v1.17.45 h1:uGK0y4+aLipE/M0YIQ5hcsWv0ZG0E4cPv03a94K/eLE=
github.com/luxfi/crypto v1.17.45/go.mod h1:GnAkhQ7HNs3X0Tzx5nOONS3kl0yRmWHbDcRO5ffILsg=
github.com/luxfi/database v1.17.44 h1:hfiTls7sqbweW+o4iaZqB8P997paC+vpgWmhN6v5MJ0=
github.com/luxfi/database v1.17.44/go.mod h1:6Ey5y3I0WNLHbxIlIdFqUuKfBg+b0fAgTA8FgRgQ8zg=
github.com/luxfi/edwards25519 v0.1.0 h1:zuSrBtgV4NUiJ8dzzJiLU4o+A0AHGnztRLUvZ24mAYk=
github.com/luxfi/edwards25519 v0.1.0/go.mod h1:tFuuyXvI+xqUoHz9pwC+kqsALCGHSO1SymL1NjCOmFM=
github.com/luxfi/edwards25519 v0.1.0 h1:YPoT831TZMslvNyy/KuTtpHi4BGZWgpwak06RCaYLzo=
github.com/luxfi/edwards25519 v0.1.0/go.mod h1:MBoV+bPEz1tSMADPMSUaYdZ+agY3syG8B6z7RmJBH0A=
github.com/luxfi/fhe v1.7.7 h1:RP26i3CxvNy2y/3LKiJPgd9uR5MrlG/MvQnv5rkQFhk=
github.com/luxfi/fhe v1.7.7/go.mod h1:FHfGNgKL5ZLcnKVKFVNLDJOBbQyQm5DGpVrMnY8ASJo=
github.com/luxfi/geth v1.16.79 h1:MtP8ZUuSVZDjmZDa1kTJCl0PpG8+wGbQft652GO6a3A=
@@ -398,6 +402,8 @@ github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
github.com/luxfi/threshold v1.6.0 h1:pq/n1iYowws+CXe/+slJodjPlJ152hFF94luHp2E82c=
github.com/luxfi/threshold v1.6.0/go.mod h1:XWSfWM3n/tVggluh/LH7is4vYL85zmyMHCqfz2qrYVU=
github.com/luxfi/threshold v1.6.4 h1:SDExhsRJovCccvCQwWK5TUpa5u4upXGEnEDW/XGB7/k=
github.com/luxfi/threshold v1.6.4/go.mod h1:jNBQbeBMmxz9xYSWIer2kOMqq49mYIszSZwTdk1odc0=
github.com/luxfi/utils v1.1.4 h1:8OY0jXCkpp6OotN1Y++6DATJkvtEwzq2k0p56imJlAk=
github.com/luxfi/utils v1.1.4/go.mod h1:c3yz1RjzrB+cs5GZm+q1T3/2cCKElO9vxm9yRRtgSEM=
github.com/luxfi/zap v0.2.0 h1:RzvOkp3EoN5UCkpnqfObHLM1sEHy7YcxXKuermhE/VA=
@@ -593,6 +599,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+9
View File
@@ -16,6 +16,7 @@ import (
"github.com/luxfi/mpc/pkg/custody"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/txtracker"
"github.com/luxfi/mpc/pkg/wallet"
"github.com/luxfi/mpc/pkg/webauthn"
)
@@ -84,6 +85,14 @@ type Server struct {
router chi.Router
replayGuard *replayGuard
// approval is the executive-approval registry consumed by /v1/approval/*.
// Optional — when nil, those endpoints return 503. Wired via SetApproval.
approval *ApprovalRegistry
// tieredWallets is the tiered-wallet registry consumed by /v1/wallets/*.
// Lazily initialized when nil; wired via SetTieredWalletRegistry.
tieredWallets wallet.Registry
// Events is the broadcast channel for server-side events (intent status,
// signing progress, wallet events). Handlers publish here; WebSocket
// clients and ZAP subscribers receive.
+4 -80
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"time"
"github.com/luxfi/fhe"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/mpc/pkg/identity"
@@ -554,85 +553,10 @@ func (p *Node) CreateReshareSession(
}
}
// CreateTFHEKeyGenSession creates a threshold FHE key generation session
// TFHE enables computation on encrypted data using lattice-based cryptography
func (p *Node) CreateTFHEKeyGenSession(
walletID string,
threshold int,
resultQueue messaging.MessageQueue,
orgID string,
) (*tfheKeygenSession, error) {
if !p.peerRegistry.ArePeersReady() {
return nil, fmt.Errorf(
"peers are not ready yet. ready: %d, expected: %d",
p.peerRegistry.GetReadyPeersCount(),
len(p.peerIDs)+1,
)
}
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)
if err != nil {
return nil, fmt.Errorf("failed to create FHE parameters: %w", err)
}
session := newTFHEKeygenSession(
walletID,
p.pubSub,
selfPartyID,
allPartyIDs,
threshold,
params,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
return session, nil
}
// CreateTFHEComputeSession creates a session for threshold FHE computation
// Requires a wallet that was created with CreateTFHEKeyGenSession
func (p *Node) CreateTFHEComputeSession(
sessionID string,
walletID string,
participantPeerIDs []string,
resultQueue messaging.MessageQueue,
orgID string,
) (TFHESession, 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(
sessionID,
walletID,
p.pubSub,
selfPartyID,
participantPartyIDs,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
if err != nil {
return nil, err
}
return session, nil
}
// 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).
func contains(slice []string, item string) bool {
for _, s := range slice {
+23
View File
@@ -0,0 +1,23 @@
// 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
// LUX_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")
+100
View File
@@ -0,0 +1,100 @@
// 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.
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.
func (p *Node) CreateTFHEKeyGenSession(
walletID string,
threshold int,
resultQueue messaging.MessageQueue,
orgID string,
) (Session, error) {
if !p.peerRegistry.ArePeersReady() {
return nil, fmt.Errorf(
"peers are not ready yet. ready: %d, expected: %d",
p.peerRegistry.GetReadyPeersCount(),
len(p.peerIDs)+1,
)
}
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)
if err != nil {
return nil, fmt.Errorf("failed to create FHE parameters: %w", err)
}
session := newTFHEKeygenSession(
walletID,
p.pubSub,
selfPartyID,
allPartyIDs,
threshold,
params,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
return session, nil
}
// CreateTFHEComputeSession creates a session for threshold FHE computation.
// EXPERIMENTAL: backed by the placeholder UNSAFE primitive in
// luxfi/threshold/protocols/tfhe.
func (p *Node) CreateTFHEComputeSession(
sessionID string,
walletID string,
participantPeerIDs []string,
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(
sessionID,
walletID,
p.pubSub,
selfPartyID,
participantPartyIDs,
p.kvstore,
p.keyinfoStore,
resultQueue,
p.identityStore,
orgID,
)
if err != nil {
return nil, err
}
return session, nil
}
+49
View File
@@ -0,0 +1,49 @@
// 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 LUX_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
}
+13 -1
View File
@@ -1,6 +1,18 @@
// Copyright (c) 2024-2025, Lux Industries Inc
// Copyright (c) 2024-2026, Lux Industries Inc
// SPDX-License-Identifier: BSD-3-Clause
//go:build experimental_tfhe
// Package-level note: this file is the EXPERIMENTAL threshold-FHE wallet
// keygen / compute path. It is gated behind the `experimental_tfhe` build tag
// because the underlying primitive in luxfi/threshold/protocols/tfhe is a
// fail-loud placeholder that does NOT implement real Shamir/LWE threshold
// decryption (every party stores the full master key — see
// lps/LP-137-TFHE-REAL-THRESHOLD-SPEC.md §2.6). Default builds compile the
// stubs in tfhe_node_stub.go which return ErrTFHENotImplemented from every
// entry point. Real-threshold wiring (luxfi/lattice Shamir/LWE) is tracked
// as a multi-week separate task per LP-137 §2.6.
package mpc
import (
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
//
// TestMain opts the policy-package tests that touch
// luxfi/threshold/protocols/tfhe (RealThresholdDecryptor wires its
// PartialDecrypter / ShareAggregator) into the placeholder UNSAFE primitive
// by setting LUX_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("LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY", "1"); err != nil {
panic("policy test setup: cannot set LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY: " + err.Error())
}
os.Exit(m.Run())
}