mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
Closes a HIGH-severity production-routing risk identified in the
pulsar/corona audit (2026-05-31).
The file protocols/lss/adapters/corona.go was a paper-grade
demonstrator that named itself "Corona" — colliding with the real
luxfi/corona Ring-LWE primitive. Anyone invoking the LSS factory with
`lss.NewLSS(lss.Corona, ...)` got the toy adapter, not the production
primitive. The toy adapter has:
- A textbook-LWE "DKG" that sums each party's s_i into a combined
master secret at the aggregator. NO Lagrange threshold sharing.
The threshold parameter is decorative.
- Q = 12289 (< 2^14) — far below any meaningful LWE security bound.
- A Box-Muller-from-uniform-mod-Q Gaussian sampler using
floating-point math on secret-dependent inputs. NOT constant-time.
Side-channel leakage is GUARANTEED.
- No matching verifier.
This commit:
- adapters/corona.go +build researchpreview
- adapters/corona_test.go +build researchpreview
- adapters/corona_external_test.go new gated external-test file
- factory_corona_prod.go new — production refuses with a routing
error pointing at
luxfi/threshold/protocols/corona →
luxfi/corona
- factory_corona_research.go new — research-preview returns the toy
Tests and benchmarks in adapters_test.go and full_coverage_test.go
that referenced NewCoronaAdapter / GetRecommendedParams have moved to
corona_external_test.go (gated). Production `go test ./protocols/lss/
adapters/...` passes; research `go test -tags researchpreview` also
passes.
Production users that need PQ threshold signing must route through
luxfi/threshold/protocols/corona (which wraps luxfi/corona): real
Ring-LWE with Pedersen-DKG, canonical wire codec (corona@42838bd), and
dudect-validated CT hot paths.
(cherry picked from commit eedafda56b8e277c483d9c96e67ba40a49052246)
559 lines
16 KiB
Go
559 lines
16 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause
|
||
//go:build researchpreview
|
||
|
||
// Package adapters — LSS Corona adapter (RESEARCH PREVIEW, NOT PRODUCTION).
|
||
//
|
||
// This file builds ONLY under -tags=researchpreview. Production builds
|
||
// must NOT compile it; LSS production callers that need post-quantum
|
||
// threshold signatures must route through luxfi/threshold/protocols/corona
|
||
// → luxfi/corona (Ring-LWE production primitive with Pedersen-DKG,
|
||
// canonical wire codec, dudect-validated CT hot paths).
|
||
//
|
||
// What this file IS:
|
||
//
|
||
// - A textbook-LWE "DKG" that has every party sample its own (s_i,
|
||
// e_i) and SUM them into a combined master secret at the
|
||
// aggregator. That is additive sharing — no party holds the secret
|
||
// AND no party holds a Lagrange share either. The "threshold"
|
||
// parameter is decorative.
|
||
//
|
||
// - A "signature share" arithmetic computed mod Q with Q = 12289 (a
|
||
// TINY modulus far below the security threshold of any real LWE
|
||
// construction). No matching verifier.
|
||
//
|
||
// - A Box-Muller-from-uniform-mod-Q Gaussian sampler that is NOT
|
||
// constant-time and uses floating-point math on secret-dependent
|
||
// inputs.
|
||
//
|
||
// What this file IS NOT:
|
||
//
|
||
// - A wrapper for luxfi/corona. The naming collision is unfortunate;
|
||
// "Corona" in luxfi/threshold/protocols/lss/* names a CHAIN (Lux's
|
||
// PQ test chain) for the LSS adapter directory, NOT the actual
|
||
// Corona Ring-LWE primitive at luxfi/corona/threshold.
|
||
//
|
||
// - Constant-time.
|
||
//
|
||
// - Secure under any standard cryptographic assumption.
|
||
//
|
||
// History: this adapter was written as a paper-grade demonstrator for
|
||
// the LSS multi-chain adapter pattern before the luxfi/corona primitive
|
||
// shipped. The pulsar/corona audit (2026-05-31) flagged the name
|
||
// collision as a HIGH-severity production-routing risk: a caller
|
||
// invoking `lss.NewLSS(lss.Corona, ...)` would get this toy adapter,
|
||
// not the real primitive. The build-tag gate removes the file from
|
||
// production builds entirely; production callers get the rejecting
|
||
// `createCoronaAdapter` in factory_corona_prod.go which points them at
|
||
// luxfi/threshold/protocols/corona.
|
||
package adapters
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"math/big"
|
||
|
||
"github.com/luxfi/threshold/pkg/party"
|
||
)
|
||
|
||
// CoronaAdapter implements post-quantum threshold signatures using lattice-based cryptography
|
||
// Based on the Corona protocol: 2-round threshold signatures from LWE
|
||
type CoronaAdapter struct {
|
||
params *CoronaParams
|
||
state *CoronaState
|
||
}
|
||
|
||
// CoronaParams defines lattice parameters for different security levels
|
||
type CoronaParams struct {
|
||
N int // Lattice dimension
|
||
Q int64 // Modulus
|
||
D int // Module rank
|
||
M int // Number of samples
|
||
Sigma float64 // Gaussian parameter
|
||
SecurityLevel int // 128, 192, or 256 bits
|
||
MaxParties int // Maximum number of parties (up to 1024)
|
||
SignatureSize int // Expected signature size in bytes
|
||
}
|
||
|
||
// CoronaState maintains the current state of the Corona instance
|
||
type CoronaState struct {
|
||
Generation uint64
|
||
Threshold int
|
||
Parties []party.ID
|
||
PublicKey *CoronaPublicKey
|
||
PreprocessingStore map[string]*CoronaOfflineData
|
||
ConsumedPreproc map[string]bool
|
||
}
|
||
|
||
// CoronaPublicKey represents a lattice-based public key
|
||
type CoronaPublicKey struct {
|
||
A [][]int64 // Public matrix A ∈ Z_q^{n×m}
|
||
B []int64 // Public vector B = As + e
|
||
Params *CoronaParams
|
||
}
|
||
|
||
// CoronaSecretShare represents a party's share of the secret key
|
||
type CoronaSecretShare struct {
|
||
PartyID party.ID
|
||
S []int64 // Secret share vector
|
||
E []int64 // Error share vector
|
||
Index int
|
||
}
|
||
|
||
// CoronaOfflineData stores precomputed data for the offline phase
|
||
type CoronaOfflineData struct {
|
||
ID string
|
||
Round1Data *OfflineRound1
|
||
Round2Data *OfflineRound2
|
||
Consumed bool
|
||
}
|
||
|
||
// OfflineRound1 contains first round offline preprocessing data
|
||
type OfflineRound1 struct {
|
||
Commitments [][]byte // Commitments to shares
|
||
Nonces []int64 // Random nonces
|
||
Timestamp int64
|
||
}
|
||
|
||
// OfflineRound2 contains second round offline preprocessing data
|
||
type OfflineRound2 struct {
|
||
MaskedShares []int64 // Masked secret shares
|
||
Proofs [][]byte // Zero-knowledge proofs
|
||
}
|
||
|
||
// GetRecommendedParams returns recommended parameters for a security level
|
||
func GetRecommendedParams(securityLevel int, maxParties int) *CoronaParams {
|
||
switch securityLevel {
|
||
case 128:
|
||
return &CoronaParams{
|
||
N: 512,
|
||
Q: 12289,
|
||
D: 8,
|
||
M: 1024,
|
||
Sigma: 3.2,
|
||
SecurityLevel: 128,
|
||
MaxParties: maxParties,
|
||
SignatureSize: 13400, // ~13.4KB as per paper
|
||
}
|
||
case 192:
|
||
return &CoronaParams{
|
||
N: 768,
|
||
Q: 24593,
|
||
D: 10,
|
||
M: 1536,
|
||
Sigma: 3.5,
|
||
SecurityLevel: 192,
|
||
MaxParties: maxParties,
|
||
SignatureSize: 28600, // ~28.6KB as per test expectation
|
||
}
|
||
case 256:
|
||
return &CoronaParams{
|
||
N: 1024,
|
||
Q: 40961,
|
||
D: 12,
|
||
M: 2048,
|
||
Sigma: 3.8,
|
||
SecurityLevel: 256,
|
||
MaxParties: maxParties,
|
||
SignatureSize: 53200, // ~53.2KB as per test expectation
|
||
}
|
||
default:
|
||
return GetRecommendedParams(128, maxParties) // Default to 128-bit
|
||
}
|
||
}
|
||
|
||
// NewCoronaAdapter creates a new Corona adapter with specified parameters
|
||
func NewCoronaAdapter(securityLevel int, maxParties int) *CoronaAdapter {
|
||
if maxParties > 1024 {
|
||
maxParties = 1024 // Cap at tested maximum
|
||
}
|
||
|
||
return &CoronaAdapter{
|
||
params: GetRecommendedParams(securityLevel, maxParties),
|
||
state: &CoronaState{
|
||
Generation: 0,
|
||
PreprocessingStore: make(map[string]*CoronaOfflineData),
|
||
ConsumedPreproc: make(map[string]bool),
|
||
},
|
||
}
|
||
}
|
||
|
||
// CoronaDKG performs distributed key generation for Corona
|
||
func (r *CoronaAdapter) CoronaDKG(parties []party.ID, threshold int) (*CoronaPublicKey, map[party.ID]*CoronaSecretShare, error) {
|
||
if threshold < 1 || threshold > len(parties) {
|
||
return nil, nil, fmt.Errorf("invalid threshold %d for %d parties", threshold, len(parties))
|
||
}
|
||
|
||
if len(parties) > r.params.MaxParties {
|
||
return nil, nil, fmt.Errorf("too many parties: %d > %d", len(parties), r.params.MaxParties)
|
||
}
|
||
|
||
// Generate public matrix A
|
||
A := r.generatePublicMatrix()
|
||
|
||
// Each party generates a secret share
|
||
shares := make(map[party.ID]*CoronaSecretShare)
|
||
combinedS := make([]int64, r.params.N)
|
||
combinedE := make([]int64, r.params.N)
|
||
|
||
for i, pid := range parties {
|
||
// Generate secret and error vectors from Gaussian distribution
|
||
s := r.sampleGaussianVector(r.params.N)
|
||
e := r.sampleGaussianVector(r.params.N)
|
||
|
||
shares[pid] = &CoronaSecretShare{
|
||
PartyID: pid,
|
||
S: s,
|
||
E: e,
|
||
Index: i,
|
||
}
|
||
|
||
// Accumulate for public key
|
||
for j := 0; j < r.params.N; j++ {
|
||
combinedS[j] = (combinedS[j] + s[j]) % r.params.Q
|
||
combinedE[j] = (combinedE[j] + e[j]) % r.params.Q
|
||
}
|
||
}
|
||
|
||
// Compute public key B = A*s + e
|
||
B := r.matrixVectorMultiply(A, combinedS)
|
||
for i := range B {
|
||
B[i] = (B[i] + combinedE[i]) % r.params.Q
|
||
}
|
||
|
||
publicKey := &CoronaPublicKey{
|
||
A: A,
|
||
B: B,
|
||
Params: r.params,
|
||
}
|
||
|
||
// Update state
|
||
r.state.Threshold = threshold
|
||
r.state.Parties = parties
|
||
r.state.PublicKey = publicKey
|
||
r.state.Generation++
|
||
|
||
return publicKey, shares, nil
|
||
}
|
||
|
||
// PreprocessOffline generates offline preprocessing data for faster online signing
|
||
func (r *CoronaAdapter) PreprocessOffline(numSessions int) error {
|
||
if r.state.PublicKey == nil {
|
||
return errors.New("no public key generated")
|
||
}
|
||
|
||
for i := 0; i < numSessions; i++ {
|
||
sessionID := fmt.Sprintf("session_%d_%d", r.state.Generation, i)
|
||
|
||
// Generate offline round 1 data
|
||
round1 := &OfflineRound1{
|
||
Commitments: r.generateCommitments(r.state.Threshold),
|
||
Nonces: r.sampleGaussianVector(r.params.M),
|
||
Timestamp: int64(i),
|
||
}
|
||
|
||
// Generate offline round 2 data
|
||
round2 := &OfflineRound2{
|
||
MaskedShares: r.sampleGaussianVector(r.params.N),
|
||
Proofs: r.generateProofs(r.state.Threshold),
|
||
}
|
||
|
||
r.state.PreprocessingStore[sessionID] = &CoronaOfflineData{
|
||
ID: sessionID,
|
||
Round1Data: round1,
|
||
Round2Data: round2,
|
||
Consumed: false,
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Digest computes message digest for Corona (identity function for PQ)
|
||
func (r *CoronaAdapter) Digest(tx interface{}) ([]byte, error) {
|
||
// For post-quantum signatures, we typically use the message directly
|
||
// or apply a quantum-resistant hash function
|
||
switch v := tx.(type) {
|
||
case []byte:
|
||
return v, nil
|
||
case string:
|
||
return []byte(v), nil
|
||
default:
|
||
return nil, fmt.Errorf("unsupported transaction type: %T", tx)
|
||
}
|
||
}
|
||
|
||
// SignEC performs threshold signing using Corona protocol
|
||
func (r *CoronaAdapter) SignEC(digest []byte, share Share) (PartialSig, error) {
|
||
// Find available preprocessing data
|
||
var offlineData *CoronaOfflineData
|
||
for id, data := range r.state.PreprocessingStore {
|
||
if !data.Consumed {
|
||
offlineData = data
|
||
r.state.PreprocessingStore[id].Consumed = true
|
||
r.state.ConsumedPreproc[id] = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if offlineData == nil {
|
||
return nil, errors.New("no available preprocessing data")
|
||
}
|
||
|
||
// For now, create a placeholder Corona secret share from scalar
|
||
// Convert curve scalar bytes to CoronaSecretShare format.
|
||
coronaShare := &CoronaSecretShare{
|
||
PartyID: share.ID,
|
||
Index: share.Index,
|
||
// Value would be the lattice element derived from share.Value
|
||
}
|
||
|
||
// Online round 1: Use preprocessed nonces
|
||
// Online round 2: Compute signature share using masked shares
|
||
sigShare := r.computeSignatureShare(digest, coronaShare, offlineData)
|
||
|
||
return &CoronaPartialSig{
|
||
PartyID: share.ID,
|
||
Share: sigShare,
|
||
}, nil
|
||
}
|
||
|
||
// AggregateEC combines Corona partial signatures
|
||
func (r *CoronaAdapter) AggregateEC(parts []PartialSig) (FullSig, error) {
|
||
if len(parts) < r.state.Threshold {
|
||
return nil, fmt.Errorf("insufficient partial signatures: %d < %d",
|
||
len(parts), r.state.Threshold)
|
||
}
|
||
|
||
// Aggregate lattice signatures
|
||
aggregated := r.aggregateLatticeSignatures(parts)
|
||
|
||
return &CoronaFullSig{
|
||
Signature: aggregated,
|
||
Size: r.params.SignatureSize,
|
||
}, nil
|
||
}
|
||
|
||
// Encode converts Corona signature to wire format
|
||
func (r *CoronaAdapter) Encode(full FullSig) ([]byte, error) {
|
||
coronaSig, ok := full.(*CoronaFullSig)
|
||
if !ok {
|
||
return nil, errors.New("invalid signature type for Corona")
|
||
}
|
||
|
||
// Encode lattice signature
|
||
encoded := r.encodeLatticeSignature(coronaSig.Signature)
|
||
|
||
// Ensure size matches expected
|
||
if len(encoded) > r.params.SignatureSize {
|
||
return nil, fmt.Errorf("signature too large: %d > %d",
|
||
len(encoded), r.params.SignatureSize)
|
||
}
|
||
|
||
// Pad if necessary
|
||
if len(encoded) < r.params.SignatureSize {
|
||
padded := make([]byte, r.params.SignatureSize)
|
||
copy(padded, encoded)
|
||
return padded, nil
|
||
}
|
||
|
||
return encoded, nil
|
||
}
|
||
|
||
// ValidateConfig validates configuration for Corona
|
||
func (r *CoronaAdapter) ValidateConfig(config *UnifiedConfig) error {
|
||
if config.SignatureScheme != SignatureCorona {
|
||
return errors.New("config not for Corona signature")
|
||
}
|
||
|
||
if config.CoronaConfig == nil {
|
||
return errors.New("missing Corona configuration")
|
||
}
|
||
|
||
// Validate security parameters
|
||
if config.CoronaConfig.SecurityLevel < 128 || config.CoronaConfig.SecurityLevel > 256 {
|
||
return fmt.Errorf("invalid security level: %d", config.CoronaConfig.SecurityLevel)
|
||
}
|
||
|
||
// Check lattice dimensions - if N is not set, use recommended params
|
||
if config.CoronaConfig.N == 0 {
|
||
// Initialize with recommended params if not set
|
||
params := GetRecommendedParams(config.CoronaConfig.SecurityLevel, len(config.PartyIDs))
|
||
config.CoronaConfig.N = params.N
|
||
config.CoronaConfig.Q = int(params.Q)
|
||
config.CoronaConfig.Sigma = params.Sigma
|
||
} else if config.CoronaConfig.N < 256 || config.CoronaConfig.N > 2048 {
|
||
return fmt.Errorf("invalid lattice dimension: %d", config.CoronaConfig.N)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Helper functions for lattice operations
|
||
|
||
func (r *CoronaAdapter) generatePublicMatrix() [][]int64 {
|
||
A := make([][]int64, r.params.N)
|
||
for i := 0; i < r.params.N; i++ {
|
||
A[i] = make([]int64, r.params.M)
|
||
for j := 0; j < r.params.M; j++ {
|
||
A[i][j] = r.randomModQ()
|
||
}
|
||
}
|
||
return A
|
||
}
|
||
|
||
func (r *CoronaAdapter) sampleGaussianVector(n int) []int64 {
|
||
vec := make([]int64, n)
|
||
for i := 0; i < n; i++ {
|
||
vec[i] = r.sampleGaussian()
|
||
}
|
||
return vec
|
||
}
|
||
|
||
func (r *CoronaAdapter) sampleGaussian() int64 {
|
||
// Box-Muller transform for Gaussian sampling
|
||
u1, _ := rand.Int(rand.Reader, big.NewInt(r.params.Q))
|
||
u2, _ := rand.Int(rand.Reader, big.NewInt(r.params.Q))
|
||
|
||
f1 := float64(u1.Int64()) / float64(r.params.Q)
|
||
f2 := float64(u2.Int64()) / float64(r.params.Q)
|
||
|
||
z := math.Sqrt(-2*math.Log(f1)) * math.Cos(2*math.Pi*f2)
|
||
sample := int64(z * r.params.Sigma)
|
||
|
||
return sample % r.params.Q
|
||
}
|
||
|
||
func (r *CoronaAdapter) randomModQ() int64 {
|
||
n, _ := rand.Int(rand.Reader, big.NewInt(r.params.Q))
|
||
return n.Int64()
|
||
}
|
||
|
||
func (r *CoronaAdapter) matrixVectorMultiply(A [][]int64, v []int64) []int64 {
|
||
result := make([]int64, len(A))
|
||
for i := range A {
|
||
sum := int64(0)
|
||
for j := range v {
|
||
if j < len(A[i]) {
|
||
sum = (sum + A[i][j]*v[j]) % r.params.Q
|
||
}
|
||
}
|
||
result[i] = sum
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (r *CoronaAdapter) generateCommitments(n int) [][]byte {
|
||
commitments := make([][]byte, n)
|
||
for i := 0; i < n; i++ {
|
||
commitment := make([]byte, 32)
|
||
rand.Read(commitment)
|
||
commitments[i] = commitment
|
||
}
|
||
return commitments
|
||
}
|
||
|
||
func (r *CoronaAdapter) generateProofs(n int) [][]byte {
|
||
proofs := make([][]byte, n)
|
||
for i := 0; i < n; i++ {
|
||
proof := make([]byte, 64)
|
||
rand.Read(proof)
|
||
proofs[i] = proof
|
||
}
|
||
return proofs
|
||
}
|
||
|
||
func (r *CoronaAdapter) computeSignatureShare(message []byte, share *CoronaSecretShare, offline *CoronaOfflineData) []int64 {
|
||
// Simplified signature share computation
|
||
// Actual implementation would follow Corona protocol specification
|
||
sigShare := make([]int64, r.params.N)
|
||
|
||
// Use offline data and secret share to compute signature share
|
||
for i := 0; i < r.params.N; i++ {
|
||
// Combine secret share with nonce and message
|
||
h := int64(0)
|
||
noncesLen := len(offline.Round1Data.Nonces)
|
||
for j := 0; j < len(message) && j < r.params.M && j < noncesLen; j++ {
|
||
h = (h + int64(message[j])*offline.Round1Data.Nonces[j]) % r.params.Q
|
||
}
|
||
|
||
// Check bounds for MaskedShares
|
||
maskedShare := int64(0)
|
||
if i < len(offline.Round2Data.MaskedShares) {
|
||
maskedShare = offline.Round2Data.MaskedShares[i]
|
||
}
|
||
|
||
// Check bounds for share.S
|
||
secretShare := int64(0)
|
||
if i < len(share.S) {
|
||
secretShare = share.S[i]
|
||
}
|
||
|
||
sigShare[i] = (secretShare + h + maskedShare) % r.params.Q
|
||
}
|
||
|
||
return sigShare
|
||
}
|
||
|
||
func (r *CoronaAdapter) aggregateLatticeSignatures(parts []PartialSig) []int64 {
|
||
if len(parts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Get first signature share to determine size
|
||
first := parts[0].(*CoronaPartialSig).Share.([]int64)
|
||
aggregated := make([]int64, len(first))
|
||
|
||
// Sum all signature shares
|
||
for _, part := range parts {
|
||
share := part.(*CoronaPartialSig).Share.([]int64)
|
||
for i := range aggregated {
|
||
aggregated[i] = (aggregated[i] + share[i]) % r.params.Q
|
||
}
|
||
}
|
||
|
||
return aggregated
|
||
}
|
||
|
||
func (r *CoronaAdapter) encodeLatticeSignature(sig interface{}) []byte {
|
||
lattice := sig.([]int64)
|
||
|
||
// Encode each coefficient as bytes
|
||
encoded := make([]byte, 0, len(lattice)*8)
|
||
for _, coeff := range lattice {
|
||
// Encode as 8 bytes
|
||
bytes := make([]byte, 8)
|
||
for i := 0; i < 8; i++ {
|
||
bytes[i] = byte(coeff >> (8 * i))
|
||
}
|
||
encoded = append(encoded, bytes...)
|
||
}
|
||
|
||
return encoded
|
||
}
|
||
|
||
// CoronaBenchmark provides performance metrics
|
||
type CoronaBenchmark struct {
|
||
DKGTime int64 // microseconds
|
||
PreprocessingTime int64 // microseconds per session
|
||
SigningTime int64 // microseconds (online only)
|
||
VerificationTime int64 // microseconds
|
||
SignatureSize int // bytes
|
||
CommunicationSize int // total bytes exchanged
|
||
}
|
||
|
||
// Benchmark runs performance tests for Corona
|
||
func (r *CoronaAdapter) Benchmark(parties int, threshold int) *CoronaBenchmark {
|
||
// This would run actual benchmarks
|
||
// Placeholder values based on paper's reported results
|
||
return &CoronaBenchmark{
|
||
DKGTime: 1000000, // 1 second for DKG
|
||
PreprocessingTime: 50000, // 50ms per session
|
||
SigningTime: 5000, // 5ms online signing
|
||
VerificationTime: 2000, // 2ms verification
|
||
SignatureSize: r.params.SignatureSize,
|
||
CommunicationSize: parties * threshold * 1024, // Estimated
|
||
}
|
||
}
|