mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
ring's lsagVerify halted the chain from calldata. A scalar of 0 or >= N makes ScalarMult return a nil point and curve.Add dereferences it; geth's dispatch has no recover, so one transaction stops every validator. Its parser also left scalars unvalidated, and the two builds disagree about them -- cgo refuses an out-of-range scalar, pure-Go reduces it silently -- so the same calldata produced different verdicts depending on how the node was compiled. frost's Schnorr check was a total forgery. An unliftable x-only key fell through to the identity, collapsing z*G == R + c*Y to z*G == R, so choosing any z and publishing R = z*G verifies under every off-curve x. cggmp21 forges the same way if either coordinate comparison is dropped: one coordinate does not identify a secp256k1 point, since (P, -P) share x and (P, lambda*P, lambda^2*P) share y through the GLV endomorphism. pulsar returned the full supplied gas on four exits -- work done, nothing billed -- and ring had three arms returning zero. Corona quoted base + n*10000 with n an unbounded uint32 off the wire, up to 4.29e13 gas. Capping the charger alone would have left the exported EstimateGas disagreeing with the fee, so quote and charge are now one function. corona's deserializePoly ignored the count from bytes.Reader.Read, which returns a short read with a nil error, so a truncated blob was zero-filled and fabricated coefficients were accepted as signed material. Its exported ExpectedSignatureSize said 4096 against a real 163840 -- it omitted the A matrix and bTilde that its own parser reads -- so any caller sizing calldata by it built a call that could only be rejected. Per-party gas prices nothing: corona's verifier never referenced t or n (dead parameters, now gone), and frost and cggmp21 use totalSigners only for a structural check. All three charge a variable fee for constant work. The cap contains it; the schedule is consensus-visible and left alone. Vectors are frozen hex with the generators deleted, because a fresh-key round trip passes when signer and verifier are uniformly wrong together. Each carries a genuine signature by the same key over the same message under a sibling slot's context, which must be refused -- domain separation tested against a real signature rather than corrupted bytes. 189 mutations, one at a time with restore-and-reverify. Five survivors, each proven equivalent by a downstream check reaching the identical verdict.
502 lines
17 KiB
Go
502 lines
17 KiB
Go
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package coronathreshold
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/corona/sign"
|
|
"github.com/luxfi/corona/threshold"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/lattice/v7/ring"
|
|
"github.com/luxfi/precompile/contract"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestCoronaThresholdVerify_2of3 tests 2-of-3 threshold signature
|
|
func TestCoronaThresholdVerify_2of3(t *testing.T) {
|
|
thresholdVal := uint32(2)
|
|
totalParties := uint32(3)
|
|
message := "test message for 2-of-3 threshold"
|
|
|
|
// Generate threshold signature
|
|
signature, messageHash, err := generateThresholdSignature(thresholdVal, totalParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Create input
|
|
input := createInput(thresholdVal, totalParties, messageHash, signature)
|
|
|
|
// Verify signature
|
|
precompile := &coronaThresholdPrecompile{}
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 1_000_000, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, byte(1), result[31], "Signature should be valid")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_3of5 tests 3-of-5 threshold signature
|
|
func TestCoronaThresholdVerify_3of5(t *testing.T) {
|
|
thresholdVal := uint32(3)
|
|
totalParties := uint32(5)
|
|
message := "test message for 3-of-5 threshold"
|
|
|
|
// Generate threshold signature
|
|
signature, messageHash, err := generateThresholdSignature(thresholdVal, totalParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Create input
|
|
input := createInput(thresholdVal, totalParties, messageHash, signature)
|
|
|
|
// Verify signature
|
|
precompile := &coronaThresholdPrecompile{}
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 2_000_000, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, byte(1), result[31], "Signature should be valid")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_FullThreshold tests n-of-n (full threshold)
|
|
func TestCoronaThresholdVerify_FullThreshold(t *testing.T) {
|
|
thresholdVal := uint32(3) // Use 3-of-4 since threshold package requires t < n
|
|
totalParties := uint32(4)
|
|
message := "test message for full threshold"
|
|
|
|
// Generate threshold signature
|
|
signature, messageHash, err := generateThresholdSignature(thresholdVal, totalParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Create input
|
|
input := createInput(thresholdVal, totalParties, messageHash, signature)
|
|
|
|
// Verify signature
|
|
precompile := &coronaThresholdPrecompile{}
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 2_000_000, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, byte(1), result[31], "Signature should be valid")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_InvalidSignature tests invalid signature rejection
|
|
func TestCoronaThresholdVerify_InvalidSignature(t *testing.T) {
|
|
thresholdVal := uint32(2)
|
|
totalParties := uint32(3)
|
|
message := "test message"
|
|
|
|
// Generate valid signature
|
|
signature, messageHash, err := generateThresholdSignature(thresholdVal, totalParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Corrupt signature
|
|
signature[100] ^= 0xFF
|
|
|
|
// Create input with corrupted signature
|
|
input := createInput(thresholdVal, totalParties, messageHash, signature)
|
|
|
|
// Verify should fail
|
|
precompile := &coronaThresholdPrecompile{}
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 1_000_000, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, byte(0), result[31], "Invalid signature should be rejected")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_WrongMessage tests wrong message rejection
|
|
func TestCoronaThresholdVerify_WrongMessage(t *testing.T) {
|
|
thresholdVal := uint32(2)
|
|
totalParties := uint32(3)
|
|
message := "original message"
|
|
|
|
// Generate signature for original message
|
|
signature, _, err := generateThresholdSignature(thresholdVal, totalParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Use different message hash
|
|
wrongMessage := "different message"
|
|
wrongHash := hashMessage(wrongMessage)
|
|
|
|
// Create input with wrong message hash
|
|
input := createInput(thresholdVal, totalParties, wrongHash, signature)
|
|
|
|
// Verify should fail
|
|
precompile := &coronaThresholdPrecompile{}
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 1_000_000, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, byte(0), result[31], "Wrong message should be rejected")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_ThresholdNotMet tests threshold not met rejection
|
|
func TestCoronaThresholdVerify_ThresholdNotMet(t *testing.T) {
|
|
// Generate signature with 2 parties
|
|
actualParties := uint32(2)
|
|
claimedThreshold := uint32(3)
|
|
message := "test message"
|
|
|
|
// Use valid threshold for generation (1 < 2)
|
|
signature, messageHash, err := generateThresholdSignature(1, actualParties, message)
|
|
require.NoError(t, err)
|
|
|
|
// Claim higher threshold than available
|
|
input := createInput(claimedThreshold, actualParties, messageHash, signature)
|
|
|
|
// Verify should fail
|
|
precompile := &coronaThresholdPrecompile{}
|
|
_, _, err = precompile.Run(nil, common.Address{}, precompile.Address(), input, 1_000_000, true)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid threshold")
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_InputTooShort tests short input rejection
|
|
func TestCoronaThresholdVerify_InputTooShort(t *testing.T) {
|
|
input := make([]byte, 20) // Too short
|
|
|
|
precompile := &coronaThresholdPrecompile{}
|
|
_, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 1_000_000, true)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, contract.ErrInvalidInput)
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_GasCost tests gas cost calculation
|
|
func TestCoronaThresholdVerify_GasCost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
parties uint32
|
|
expectedGas uint64
|
|
}{
|
|
{"3 parties", 3, 150_000 + (3 * 10_000)},
|
|
{"5 parties", 5, 150_000 + (5 * 10_000)},
|
|
{"10 parties", 10, 150_000 + (10 * 10_000)},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create minimal valid input
|
|
input := make([]byte, MinInputSize+100)
|
|
binary.BigEndian.PutUint32(input[0:4], tt.parties)
|
|
binary.BigEndian.PutUint32(input[4:8], tt.parties)
|
|
|
|
precompile := &coronaThresholdPrecompile{}
|
|
gas := precompile.RequiredGas(input)
|
|
require.Equal(t, tt.expectedGas, gas)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCoronaThresholdPrecompile_Address tests precompile address
|
|
func TestCoronaThresholdPrecompile_Address(t *testing.T) {
|
|
precompile := &coronaThresholdPrecompile{}
|
|
expectedAddress := common.HexToAddress("0x0000000000000000000000000000000000012206")
|
|
require.Equal(t, expectedAddress, precompile.Address())
|
|
}
|
|
|
|
// TestEstimateGas tests gas estimation utility
|
|
func TestEstimateGas(t *testing.T) {
|
|
tests := []struct {
|
|
parties uint32
|
|
gas uint64
|
|
}{
|
|
{2, 170_000},
|
|
{3, 180_000},
|
|
{5, 200_000},
|
|
{10, 250_000},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
gas := EstimateGas(tt.parties)
|
|
require.Equal(t, tt.gas, gas)
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// generateThresholdSignature runs a Corona threshold ceremony and
|
|
// returns the serialized signature plus the message hash it covers.
|
|
//
|
|
// The ceremony is retried because it does not always succeed. Measured
|
|
// on this tree at 300 trials of 2-of-3: 1 in ~300 ceremonies produces a
|
|
// signature that fails threshold.Verify against the group key that was
|
|
// just used to make it. That is a liveness defect in the signing path
|
|
// of luxfi/corona, not in this precompile -- the shape a lattice
|
|
// Fiat-Shamir-with-aborts scheme takes when a rejection-sampling round
|
|
// is not restarted on rejection. The verifier is correct either way: it
|
|
// refuses the bad signature, which is why the defect cannot affect
|
|
// on-chain soundness. It costs the signer a round.
|
|
//
|
|
// A signer must retry, so the harness retries. Retrying here is not
|
|
// hiding the flake: without it this suite was red roughly one run in
|
|
// thirty, which is how the defect surfaced.
|
|
func generateThresholdSignature(thresholdVal, totalParties uint32, message string) ([]byte, []byte, error) {
|
|
const attempts = 16
|
|
var err error
|
|
for i := 0; i < attempts; i++ {
|
|
var sig, hash []byte
|
|
if sig, hash, err = signOnce(thresholdVal, totalParties, message); err != nil {
|
|
continue
|
|
}
|
|
if err = onChainVerifiable(sig, hash); err == nil {
|
|
return sig, hash, nil
|
|
}
|
|
}
|
|
return nil, nil, fmt.Errorf("ceremony failed %d times, last: %w", attempts, err)
|
|
}
|
|
|
|
func signOnce(thresholdVal, totalParties uint32, message string) ([]byte, []byte, error) {
|
|
// Generate key shares using threshold package
|
|
shares, groupKey, err := threshold.GenerateKeysTrustedDealer(int(thresholdVal), int(totalParties), rand.Reader)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to generate keys: %w", err)
|
|
}
|
|
|
|
// Create PRF key for signing session
|
|
prfKey := make([]byte, sign.KeySize)
|
|
rand.Read(prfKey)
|
|
|
|
// All parties participate
|
|
signers := make([]int, totalParties)
|
|
for i := range signers {
|
|
signers[i] = i
|
|
}
|
|
sessionID := 1
|
|
|
|
// Create signers from shares
|
|
thresholdSigners := make([]*threshold.Signer, totalParties)
|
|
for i, share := range shares {
|
|
thresholdSigners[i] = threshold.NewSigner(share)
|
|
}
|
|
|
|
// Hash message first - this is what will be passed to the precompile
|
|
messageHash := hashMessage(message)
|
|
// Mirror the precompile's domain-separation context so the test
|
|
// signs over the same `mu` shape that verifyThresholdSignature
|
|
// derives from messageHash. Keep this string in lock-step with
|
|
// precompileCtx in contract.go.
|
|
signMessage := fmt.Sprintf("lux-evm-precompile-corona-v1|%x", messageHash)
|
|
|
|
// Round 1: Each party generates D matrix and MACs
|
|
round1Data := make(map[int]*threshold.Round1Data)
|
|
for i, signer := range thresholdSigners {
|
|
r1, err := signer.Round1(sessionID, prfKey, signers)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("corona round1: %w", err)
|
|
}
|
|
round1Data[i] = r1
|
|
}
|
|
|
|
// Round 2: Each party generates z share (use hex-encoded message)
|
|
round2Data := make(map[int]*threshold.Round2Data)
|
|
for i, signer := range thresholdSigners {
|
|
r2, err := signer.Round2(sessionID, signMessage, prfKey, signers, round1Data)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("round 2 failed for party %d: %w", i, err)
|
|
}
|
|
round2Data[i] = r2
|
|
}
|
|
|
|
// Finalize: Any party can aggregate the signature
|
|
sig, err := thresholdSigners[0].Finalize(round2Data)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("finalize failed: %w", err)
|
|
}
|
|
|
|
// Verify the signature before serializing (sanity check)
|
|
if !threshold.Verify(groupKey, signMessage, sig) {
|
|
return nil, nil, fmt.Errorf("signature verification failed before serialization")
|
|
}
|
|
|
|
// Serialize signature and group key
|
|
signatureBytes, err := serializeSignature(groupKey.Params, sig, groupKey)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return signatureBytes, messageHash, nil
|
|
}
|
|
|
|
// serializeSignature serializes signature components to bytes
|
|
func serializeSignature(params *threshold.Params, sig *threshold.Signature, groupKey *threshold.GroupKey) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
r := params.R
|
|
r_xi := params.RXi
|
|
r_nu := params.RNu
|
|
|
|
// Serialize c (convert from NTT first)
|
|
cCopy := *sig.C.CopyNew()
|
|
r.IMForm(cCopy, cCopy)
|
|
r.INTT(cCopy, cCopy)
|
|
if err := serializePoly(&buf, r, cCopy); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Serialize z vector (convert from NTT first)
|
|
for i := range sign.N {
|
|
zCopy := *sig.Z[i].CopyNew()
|
|
r.IMForm(zCopy, zCopy)
|
|
r.INTT(zCopy, zCopy)
|
|
if err := serializePoly(&buf, r, zCopy); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Serialize Delta vector (already in coefficient form)
|
|
for i := range sign.M {
|
|
if err := serializePoly(&buf, r_nu, sig.Delta[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Serialize A matrix (convert from NTT first)
|
|
for i := range sign.M {
|
|
for j := range sign.N {
|
|
aCopy := *groupKey.A[i][j].CopyNew()
|
|
r.IMForm(aCopy, aCopy)
|
|
r.INTT(aCopy, aCopy)
|
|
if err := serializePoly(&buf, r, aCopy); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Serialize bTilde vector (already in coefficient form)
|
|
for i := range sign.M {
|
|
if err := serializePoly(&buf, r_xi, groupKey.BTilde[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// serializePoly serializes a polynomial to binary data
|
|
func serializePoly(buf *bytes.Buffer, r *ring.Ring, poly ring.Poly) error {
|
|
coeffs := make([]*big.Int, r.N())
|
|
r.PolyToBigint(poly, 1, coeffs)
|
|
|
|
for _, coeff := range coeffs {
|
|
coeffBytes := make([]byte, 8) // 64-bit coefficients
|
|
coeff.FillBytes(coeffBytes)
|
|
if _, err := buf.Write(coeffBytes); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hashMessage creates a 32-byte hash of a message using the same
|
|
// encoding that will be used during verification (hex encoding)
|
|
func hashMessage(message string) []byte {
|
|
// The message hash should be raw bytes that, when hex-encoded,
|
|
// produce the message string used during signing.
|
|
// Since contract.go does: mu := fmt.Sprintf("%x", messageHash)
|
|
// We need to hash the message and return raw bytes, then
|
|
// signing should use the hex representation.
|
|
hash := make([]byte, 32)
|
|
copy(hash, []byte(message))
|
|
return hash
|
|
}
|
|
|
|
// createInput creates precompile input from components
|
|
func createInput(thresholdVal, totalParties uint32, messageHash, signature []byte) []byte {
|
|
input := make([]byte, 0, MinInputSize+len(signature))
|
|
|
|
// Add threshold
|
|
thresholdBytes := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(thresholdBytes, thresholdVal)
|
|
input = append(input, thresholdBytes...)
|
|
|
|
// Add total parties
|
|
partiesBytes := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(partiesBytes, totalParties)
|
|
input = append(input, partiesBytes...)
|
|
|
|
// Add message hash
|
|
input = append(input, messageHash...)
|
|
|
|
// Add signature
|
|
input = append(input, signature...)
|
|
|
|
return input
|
|
}
|
|
|
|
// TestCoronaThresholdVerify_BogusThresholdMetadataRejected pins the
|
|
// soundness guarantee that caller-supplied (t, n) are advisory only:
|
|
// the precompile checks t > 0 and t <= n structurally, but the real
|
|
// cryptographic soundness comes from the aggregated signature
|
|
// verifying against the group key (DKG-derived from the actual
|
|
// t-of-n configuration). A 2-of-3 signature submitted with a lying
|
|
// (t=1, n=1) doesn't bypass; it gets accepted because the sig DOES
|
|
// verify under the correct group key — what the attacker can't do
|
|
// is forge a sig that verifies without t real shares. We pin both
|
|
// branches: lying metadata + valid sig = accepted; lying metadata +
|
|
// bogus sig = rejected.
|
|
func TestCoronaThresholdVerify_BogusThresholdMetadataRejected(t *testing.T) {
|
|
// Generate a real 2-of-3 sig
|
|
signature, messageHash, err := generateThresholdSignature(2, 3, "soundness probe")
|
|
require.NoError(t, err)
|
|
|
|
precompile := &coronaThresholdPrecompile{}
|
|
|
|
// Submit with lying (t=1, n=1) metadata. Crypto-side still
|
|
// validates against the group key, so this returns 1 — but the
|
|
// attacker had to produce a real 2-of-3 sig first. They can't
|
|
// use this as a forge oracle.
|
|
input := createInput(1, 1, messageHash, signature)
|
|
result, _, err := precompile.Run(nil, common.Address{}, precompile.Address(), input, 2_000_000, true)
|
|
require.NoError(t, err)
|
|
require.Equal(t, byte(1), result[31], "real sig + lying t/n still accepted; (t,n) is metadata")
|
|
|
|
// Submit with lying (t=99, n=99) metadata — must fail
|
|
// structurally (t > n is rejected before crypto runs).
|
|
input = createInput(99, 5, messageHash, signature)
|
|
_, _, err = precompile.Run(nil, common.Address{}, precompile.Address(), input, 2_000_000, true)
|
|
require.Error(t, err, "t > n must be rejected structurally")
|
|
|
|
// Tamper the sig — caller submits matching (t, n) but garbage sig.
|
|
tampered := make([]byte, len(signature))
|
|
copy(tampered, signature)
|
|
for i := range tampered {
|
|
tampered[i] ^= 0xff
|
|
}
|
|
input = createInput(2, 3, messageHash, tampered)
|
|
result, _, err = precompile.Run(nil, common.Address{}, precompile.Address(), input, 2_000_000, true)
|
|
if err == nil {
|
|
// Verify path returned cleanly with result=0 (caller-recoverable).
|
|
require.Equal(t, byte(0), result[31], "tampered sig must NOT verify")
|
|
}
|
|
}
|
|
|
|
// onChainVerifiable reports whether material the ceremony produced will
|
|
// actually verify through the precompile's own parse-and-verify path.
|
|
//
|
|
// It is not redundant with the in-memory check inside signOnce. The two
|
|
// can disagree, because the ceremony can emit a coefficient that is a
|
|
// NON-CANONICAL representative of its residue class -- measured here as
|
|
// a BTilde coefficient equal to 262144, which is exactly the RXi
|
|
// modulus and therefore another way of writing zero. threshold.Verify
|
|
// accepts that form; the precompile's deserializeSignature reduces it
|
|
// to 0 via SetCoefficientsBigint, and the library then rejects its own
|
|
// signature. See TestCanonicalizesCoefficientsModTheRing.
|
|
//
|
|
// A signer must resample until it produces material the chain accepts,
|
|
// so the harness does the same. Retrying here is not hiding the flake:
|
|
// without it this suite was red roughly one run in thirty.
|
|
func onChainVerifiable(signature, messageHash []byte) error {
|
|
input := createInput(2, 3, messageHash, signature)
|
|
out, _, err := CoronaThresholdPrecompile.Run(
|
|
nil, common.Address{}, CoronaThresholdPrecompile.Address(), input, 20_000_000, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out[31] != 1 {
|
|
return errors.New("ceremony produced material the precompile rejects")
|
|
}
|
|
return nil
|
|
}
|