mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
The stack had two different numbers called "threshold". cmp.Keygen and
frost.Keygen take t, the polynomial degree, and need t+1 signers. Operators,
dashboards and runbooks say k-of-n. Provisioning a "3-of-5" wallet by passing
threshold=3 produces a 4-of-5, silently — nothing errors, and the mistake only
surfaces when three custodians hold a ceremony and cannot sign a key that
already holds value.
pkg/quorum is now the single place those two numbers meet. Policy{K,N} states
what operators mean; Degree() == K-1 is the only k->t conversion in the tree.
Callers pass policy.Degree() and never compute the off-by-one themselves.
Proofs, all against the real protocols rather than mocks:
protocols/quorum_3of5_test.go — five-party CGGMP21 DKG at the policy degree,
three parties sign, and the signature both verifies under the group key and
ECRECOVERS to the group's EVM address. Recovery is the part that matters for
custody: a key can verify correctly and still resolve to an address no one
controls. Four different 3-subsets all sign for one address. Negatives: every
2-of-5 subset is refused, and a forged third share cannot produce a signature
that verifies. FROST covered at the same policy.
protocols/safe_3of5_test.go — the custody claim end to end. Real SafeL2 v1.5.0
bytecode behind the real SafeProxyFactory on the luxfi/geth EVM, owned solely
by the 3-of-5 group address. The digest comes from the deployed Safe's own
getTransactionHash, so the test cannot sign something the contract would not
check. execTransaction succeeds, value moves, the nonce advances, a replay is
rejected, and a valid signature from a different 3-of-5 key is rejected.
Fixes found along the way:
protocols/lss/adapters/evm.go — GenerateEVMAddress hashed the COMPRESSED
point, i.e. 32 bytes of X, where the EVM requires Keccak over the 64-byte
uncompressed X||Y. Every address it produced was well-formed and belonged to
no key; value sent to one is unspendable. It had no callers, so this was a
loaded gun rather than a live wound. Now decompresses, and reports failure
instead of returning a plausible wrong answer.
internal/test/harness.go — WithTimeout moved the harness deadline but not the
handler's own ProtocolTimeout, which was hard-coded to five minutes, so a
raised timeout silently did nothing and a CGGMP21 DKG under -race died at
five minutes blaming the context. The harness now owns one deadline. Also
removes a duplicate RunProtocolWithTimeout.
protocols/cmp/cmp_threshold_test.go — cases labelled "3-of-5" passed degree 3
and signed with four parties. They were 4-of-5. Labels now derive from the
policy, so the name and the behaviour are one fact.
go.sum — was missing entries for luxfi/metric and mattn/go-isatty; the FROST
packages did not compile from a clean checkout.
Co-authored-by: Hanzo Dev <[email protected]>
120 lines
3.9 KiB
Go
120 lines
3.9 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package adapters
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"testing"
|
|
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
"golang.org/x/crypto/sha3"
|
|
|
|
"github.com/luxfi/threshold/pkg/math/curve"
|
|
"github.com/luxfi/threshold/pkg/math/sample"
|
|
)
|
|
|
|
// GenerateEVMAddress must agree with what an EVM node computes when it
|
|
// ecrecovers a signature: Keccak256 over the 64-byte uncompressed public key,
|
|
// low 20 bytes.
|
|
//
|
|
// The regression this pins: curve.Point.MarshalBinary returns the 33-byte
|
|
// COMPRESSED encoding, so deriving from pubBytes[1:] hashes the 32-byte X
|
|
// coordinate alone. That yields a syntactically valid address for every key,
|
|
// belonging to no key at all — an unspendable destination that no unit test
|
|
// comparing "an address" to "an address" would catch. Only comparing against
|
|
// an independent derivation from the uncompressed key finds it.
|
|
func TestGenerateEVMAddressMatchesUncompressedKeccak(t *testing.T) {
|
|
e := NewEVMAdapter(Ethereum)
|
|
group := curve.Secp256k1{}
|
|
|
|
for i := 0; i < 256; i++ {
|
|
secret := sample.Scalar(rand.Reader, group)
|
|
point := secret.ActOnBase()
|
|
|
|
got, ok := e.GenerateEVMAddress(point)
|
|
if !ok {
|
|
t.Fatalf("iteration %d: GenerateEVMAddress reported failure on a valid point", i)
|
|
}
|
|
|
|
// Independent reference derivation straight from the secret scalar,
|
|
// via decred's secp256k1 rather than the adapter's own code path.
|
|
compressed, err := point.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: marshal: %v", i, err)
|
|
}
|
|
pub, err := secp256k1.ParsePubKey(compressed)
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: parse: %v", i, err)
|
|
}
|
|
uncompressed := pub.SerializeUncompressed()
|
|
if len(uncompressed) != 65 || uncompressed[0] != 0x04 {
|
|
t.Fatalf("iteration %d: bad uncompressed encoding", i)
|
|
}
|
|
h := sha3.NewLegacyKeccak256()
|
|
h.Write(uncompressed[1:])
|
|
var want [20]byte
|
|
copy(want[:], h.Sum(nil)[12:])
|
|
|
|
if got != want {
|
|
t.Fatalf("iteration %d: address = 0x%x, want 0x%x", i, got, want)
|
|
}
|
|
|
|
// The specific wrong answer the old code produced, spelled out, so
|
|
// that reintroducing it fails loudly rather than silently.
|
|
hWrong := sha3.NewLegacyKeccak256()
|
|
hWrong.Write(compressed[1:]) // 32-byte X only
|
|
var compressedDerived [20]byte
|
|
copy(compressedDerived[:], hWrong.Sum(nil)[12:])
|
|
if got == compressedDerived {
|
|
t.Fatalf("iteration %d: address was derived from the COMPRESSED point; "+
|
|
"EVM requires Keccak over uncompressed X‖Y", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Both Y-parities must derive correctly. An implementation that drops parity
|
|
// (or assumes even-Y) is right for about half of all keys, which is exactly
|
|
// the sort of bug that survives a small test sample.
|
|
func TestGenerateEVMAddressHandlesBothYParities(t *testing.T) {
|
|
e := NewEVMAdapter(Ethereum)
|
|
group := curve.Secp256k1{}
|
|
|
|
seenEven, seenOdd := false, false
|
|
for i := 0; i < 512 && !(seenEven && seenOdd); i++ {
|
|
point := sample.Scalar(rand.Reader, group).ActOnBase()
|
|
compressed, err := point.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
addr, ok := e.GenerateEVMAddress(point)
|
|
if !ok {
|
|
t.Fatalf("GenerateEVMAddress failed on a valid point")
|
|
}
|
|
if addr == ([20]byte{}) {
|
|
t.Fatalf("derived the zero address for parity byte 0x%02x", compressed[0])
|
|
}
|
|
|
|
switch compressed[0] {
|
|
case 0x02:
|
|
seenEven = true
|
|
case 0x03:
|
|
seenOdd = true
|
|
default:
|
|
t.Fatalf("unexpected compressed prefix 0x%02x", compressed[0])
|
|
}
|
|
}
|
|
if !seenEven || !seenOdd {
|
|
t.Fatalf("did not exercise both Y parities (even=%v odd=%v)", seenEven, seenOdd)
|
|
}
|
|
}
|
|
|
|
// The identity point has no address. Returning ok=false is the contract;
|
|
// returning a plausible-looking [20]byte would be the dangerous behaviour.
|
|
func TestGenerateEVMAddressRejectsIdentity(t *testing.T) {
|
|
e := NewEVMAdapter(Ethereum)
|
|
if _, ok := e.GenerateEVMAddress(curve.Secp256k1{}.NewPoint()); ok {
|
|
t.Fatal("GenerateEVMAddress accepted the identity point; it has no EVM address")
|
|
}
|
|
}
|