feat: enable Go 1.26 runtime/secret for forward secrecy of key shares

Add GOEXPERIMENT=runtimesecret to Makefile, CI, and Dockerfile builds.
Wrap key share loading in all 5 signing session types with
withSecretErasure() so stack/register state containing raw share bytes
is zeroed after parsing. Build-tagged stub ensures everything compiles
without the experiment flag.
This commit is contained in:
2025-12-27 23:20:52 -08:00
parent d2e33e1d45
commit 7eaffc4394
10 changed files with 120 additions and 55 deletions
+4
View File
@@ -9,6 +9,8 @@ on:
jobs:
test:
runs-on: ubuntu-latest
env:
GOEXPERIMENT: runtimesecret
steps:
- name: Checkout code
@@ -294,6 +296,8 @@ jobs:
build:
runs-on: lux-build-linux-amd64
needs: [test, lint, codeql-analysis, sbom]
env:
GOEXPERIMENT: runtimesecret
steps:
- name: Checkout code
+1
View File
@@ -12,6 +12,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV GOEXPERIMENT=runtimesecret
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o mpcd ./cmd/mpcd
FROM alpine:3.21
+11 -6
View File
@@ -2,6 +2,11 @@
BIN_DIR := bin
# Go 1.26 experimental features:
# runtimesecret - zeroes stack/register state after secret.Do() for forward secrecy
# simd - SIMD intrinsics (amd64/arm64)
GOEXPERIMENT ?= runtimesecret,simd
# Default target
all: build
@@ -10,27 +15,27 @@ build: lux-mpc lux-mpc-cli lux-mpc-bridge
# Install lux-mpc (builds and places it in $GOBIN or $GOPATH/bin)
lux-mpc:
GOWORK=off go build -o lux-mpc ./cmd/lux-mpc
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o lux-mpc ./cmd/lux-mpc
# Install lux-mpc-cli
lux-mpc-cli:
GOWORK=off go build -o lux-mpc-cli ./cmd/lux-mpc-cli
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o lux-mpc-cli ./cmd/lux-mpc-cli
# Install lux-mpc-bridge (bridge compatibility)
lux-mpc-bridge:
GOWORK=off go build -o lux-mpc-bridge ./cmd/lux-mpc-bridge 2>/dev/null || true
GOWORK=off GOEXPERIMENT=$(GOEXPERIMENT) go build -o lux-mpc-bridge ./cmd/lux-mpc-bridge 2>/dev/null || true
# Run all tests
test:
go test ./...
GOEXPERIMENT=$(GOEXPERIMENT) go test ./...
# Run tests with verbose output
test-verbose:
go test -v ./...
GOEXPERIMENT=$(GOEXPERIMENT) go test -v ./...
# Run tests with coverage report
test-coverage:
go test -v -coverprofile=coverage.out ./...
GOEXPERIMENT=$(GOEXPERIMENT) go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Run E2E integration tests
+17 -9
View File
@@ -61,16 +61,24 @@ func newBLSSigningSession(
identityStore identity.Store,
orgID string,
) (*blsSigningSession, error) {
// Load config from kvstore - BLS keys are stored with bls: prefix
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
blsKey := fmt.Sprintf("bls:%s", walletID)
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, blsKey)
if err != nil {
return nil, fmt.Errorf("failed to get BLS key share: %w", err)
}
config, err := UnmarshalBLSConfig(shareBytes)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal BLS key share: %w", err)
var config *blsThreshold.Config
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, blsKey)
if err != nil {
loadErr = fmt.Errorf("failed to get BLS key share: %w", err)
return
}
config, err = UnmarshalBLSConfig(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal BLS key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
return &blsSigningSession{
+10
View File
@@ -0,0 +1,10 @@
//go:build goexperiment.runtimesecret
package mpc
import "runtime/secret"
// withSecretErasure runs f inside runtime/secret.Do, which ensures that any
// temporary stack/register state produced by f is zeroed after f returns.
// This provides forward secrecy for key material used during MPC signing.
func withSecretErasure(f func()) { secret.Do(f) }
+7
View File
@@ -0,0 +1,7 @@
//go:build !goexperiment.runtimesecret
package mpc
// withSecretErasure is a no-op stub when runtime/secret is not available.
// The signing operation runs identically; secret erasure is simply skipped.
func withSecretErasure(f func()) { f() }
+16 -10
View File
@@ -62,17 +62,23 @@ func newCGGMP21SigningSession(
useBroadcast bool,
orgID string,
) (*cggmp21SigningSession, error) {
// Load config from kvstore with org-scoped fallback
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, walletID)
if err != nil {
return nil, fmt.Errorf("failed to get key share: %w", err)
}
// Create empty config with the correct group - REQUIRED for unmarshalling
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
config := config.EmptyConfig(curve.Secp256k1{})
// Use UnmarshalBinary (CBOR) instead of JSON - required for curve.Curve interface
if err := config.UnmarshalBinary(shareBytes); err != nil {
return nil, fmt.Errorf("failed to unmarshal key share: %w", err)
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, walletID)
if err != nil {
loadErr = fmt.Errorf("failed to get key share: %w", err)
return
}
// Use UnmarshalBinary (CBOR) instead of JSON - required for curve.Curve interface
if err := config.UnmarshalBinary(shareBytes); err != nil {
loadErr = fmt.Errorf("failed to unmarshal key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
// Create thread pool
+18 -10
View File
@@ -64,17 +64,25 @@ func newFROSTSigningSession(
useBroadcast bool,
orgID string,
) (*frostSigningSession, error) {
// Load config from kvstore - FROST keys are stored with frost: prefix
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
frostKey := fmt.Sprintf("frost:%s", walletID)
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, frostKey)
if err != nil {
return nil, fmt.Errorf("failed to get FROST key share: %w", err)
}
// TaprootConfig is stored as CBOR (to properly preserve crypto types)
config, err := UnmarshalFROSTConfig(shareBytes)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal FROST key share: %w", err)
var config *frost.TaprootConfig
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, frostKey)
if err != nil {
loadErr = fmt.Errorf("failed to get FROST key share: %w", err)
return
}
// TaprootConfig is stored as CBOR (to properly preserve crypto types)
config, err = UnmarshalFROSTConfig(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal FROST key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
// BIP-340/Taproot requires 32-byte message hash
+18 -10
View File
@@ -60,16 +60,24 @@ func newLSSSigningSession(
useBroadcast bool,
orgID string,
) (*lssSigningSession, error) {
// Load config from kvstore with org-scoped fallback
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, walletID)
if err != nil {
return nil, fmt.Errorf("failed to get key share: %w", err)
}
// Unmarshal config using CBOR (not JSON) to preserve curve types
config, err := UnmarshalLSSConfig(shareBytes)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal key share: %w", err)
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
var config *lssConfig.Config
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, walletID)
if err != nil {
loadErr = fmt.Errorf("failed to get key share: %w", err)
return
}
// Unmarshal config using CBOR (not JSON) to preserve curve types
config, err = UnmarshalLSSConfig(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
// Normalize message hash to 32 bytes (LSS requires exactly 32 bytes)
+18 -10
View File
@@ -75,17 +75,25 @@ func newSR25519SigningSession(
useBroadcast bool,
orgID string,
) (*sr25519SigningSession, error) {
// Load config from kvstore - SR25519 keys are stored with sr25519: prefix
// Load and unmarshal key share inside withSecretErasure so that raw
// share bytes on the stack are zeroed after parsing completes.
sr25519Key := fmt.Sprintf("sr25519:%s", walletID)
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, sr25519Key)
if err != nil {
return nil, fmt.Errorf("failed to get SR25519 key share: %w", err)
}
// Config is stored as CBOR (to properly preserve crypto types)
config, err := UnmarshalSR25519Config(shareBytes)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal SR25519 key share: %w", err)
var config *frost.Config
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, sr25519Key)
if err != nil {
loadErr = fmt.Errorf("failed to get SR25519 key share: %w", err)
return
}
// Config is stored as CBOR (to properly preserve crypto types)
config, err = UnmarshalSR25519Config(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal SR25519 key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
// Default signing context