Files
zeekay e5dbe95587 lss: gate research-preview Corona adapter behind -tags=researchpreview
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)
2026-05-31 22:53:11 -07:00

559 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}