Ed25519 keygen is real: sol_address comes from KeygenEd25519 and nothing else

luxfi/threshold v1.12.7 added FROST(Ed25519, SHA-512), so Solana and TON finally
have a signable curve. The sessions named "EdDSA" were calling KeygenTaproot —
BIP-340 over secp256k1 — whose key is also 32 bytes, sails through the
len(EDDSAPubKey)==32 gate, and base58-encodes into a real-looking Solana address
the ring can never sign for.

⚠ A LIVE WRONG-ADDRESS BUG went with it: handlers_wallets served
addresses["ton"] = *wallet.SolAddress. A TON address is not a base58 pubkey — it
is workchain plus a hash of the wallet contract's StateInit, base64url with a
CRC16 — so the API was handing out an address that decodes to nothing. Deleted
rather than guessed; TON returns when its encoding is real.

The keygen tests used to call frost.KeygenEd25519 directly, which validated the
LIBRARY and not the daemon: reintroducing KeygenTaproot left every package
green. They now go through the production session, and so does a test that
constructs the real signing session and proves the caller's bytes reach the
protocol unhashed. Both original bugs reintroduced together fail 11 tests.

secp256k1 keygen changes from fail-open to fail-closed, and that is the one
behavioural change to the existing EVM/BTC path. An empty pubkey previously
meant any of nil config, marshal failure, kvstore.Put failure or keyinfo-save
failure — and was returned as SUCCESS, announcing addresses while fewer than
t+1 nodes hold a share. Applying fail-closed to Solana while leaving the
higher-value leg fail-open is incoherent.

Also: the KMS share classifier still keyed off the retired "frost:" prefix; the
CGGMP21 session published its own local failure onto the same first-wins subject
as the ring's verdict, so one node's failure could beat the ring's success; and
one 120s deadline shared across two sequential ceremonies starved the cheap leg.

NOT YET SPENDABLE. ConsensusKeyInfoStore.Save is the only RegisterKey caller and
hardcodes "secp256k1", and TriggerSign reads the curve from that field, so no
product entry point can reach Ed25519 signing. The address is correct; signing
it needs a wire-contract change shared with lux/wallet.
This commit is contained in:
2026-08-06 13:23:18 -07:00
parent 843df3d79a
commit cb77fab979
25 changed files with 1846 additions and 315 deletions
+54
View File
@@ -2,6 +2,60 @@
This document provides comprehensive guidance for AI assistants working with the MPC (Multi-Party Computation) codebase.
## ✅ `eddsa_pub_key` / `sol_address` are LIVE — Ed25519, and only from `KeygenEd25519`
Solana/TON keys are real as of `luxfi/threshold` **v1.12.7**, which added the
FROST(Ed25519, SHA-512) ciphersuite of RFC 9591. This section previously said the
opposite and told you to leave `eddsa_pub_key` empty; that blocker is cleared.
**The hazard it replaced, which has NOT gone away as a class.** The sessions named
"EdDSA" used to call `frost.KeygenTaproot` — BIP-340 over **secp256k1**. Its public key
is also 32 bytes, so it passed mpcd's `len(result.EDDSAPubKey) == 32` gate and
base58-encoded into a real-looking Solana address the ring could never sign for.
Deposits to such an address are unrecoverable.
**The rules that keep it fixed:**
- Any value reaching `EDDSAPubKey` MUST come from `frost.KeygenEd25519` and nothing else.
`KeygenTaproot` (secp256k1) and `frost.Keygen(curve.Ed25519{}, …)` (BLAKE3 challenge,
big-endian `S`) both produce plausible-looking 32-byte keys that no chain accepts.
- Sign with `frost.SignEd25519` only. Generic `frost.Sign` over the *same* Ed25519 config
returns 64 bytes shaped exactly like a signature, with a BLAKE3 challenge and big-endian
`S`. `SignEd25519`'s `Result()` is a **value** (`frost.Ed25519Signature`), not a pointer.
- **Never pre-hash.** PureEdDSA hashes the message inside the challenge. The old signing
session SHA-256'd anything that was not already 32 bytes (a BIP-340 requirement); that is
removed, and re-adding it signs a digest of a digest.
- **Provenance is the defence, not validation.** `pkg/address.Solana` checks the bytes
decode as a prime-order edwards25519 point, but that is only a backstop: measured, it
refuses ~93.4% of secp256k1 x-only keys, because about 1 in 16 of them is *genuinely* a
valid Ed25519 key too. No function of the bytes alone can do better. It also rejects the
identity point, for which any signature verifies — the DKG does not.
- When anything is uncertain, emit **nothing**. An absent `sol_address` is recoverable; a
wrong one is not. The Ed25519 keygen leg is deliberately non-fatal for this reason.
**Layout.** Keygen `pkg/mpc/frost_keygen_session.go` → share at `ed25519:<walletID>`
(`Ed25519ShareKey`, CBOR via `ed25519_config_marshal.go`), topics `keygen:*:ed25519:*`.
Signing `pkg/mpc/signing_session_frost.go` reads the same key. Orchestration is
`handleKeyGenEvent` in `pkg/eventconsumer/event_consumer.go`, which runs the secp256k1 leg
then the Ed25519 leg and publishes one result. **That order is load-bearing**: only the
CGGMP21 session writes `keyinfo`, and `CreateEdDSASignSession` reads it to count signers,
so the Ed25519 leg must never run first or alone. Shares written under the old `frost:`
prefix were secp256k1 and are intentionally unreachable.
Pinned by `pkg/mpc/ed25519_spendable_test.go`, which runs the real ceremony, round-trips
every share through the production codec, derives the address, decodes it back, and
verifies a threshold signature with `crypto/ed25519` against the key **the address decodes
to** — plus `pkg/address`, `cmd/mpcd`, and `pkg/eventconsumer` guards.
**Still open:** TON addresses are NOT emitted. The Ed25519 key *is* the correct TON key;
only the address encoding is missing (workchain ‖ SHA-256 of a wallet contract's StateInit
cell, base64url + CRC16 — and v4r2 vs W5 give different addresses for the same key).
Guessing it recreates exactly the failure above. Also open: resharing an Ed25519 wallet
(`eddsa_resharing_session.go` + `pkg/protocol/frost/adapter.go` are still Taproot/secp256k1
and read the *bare* walletID key, so they never touch the Ed25519 share); mpcd's `/sign`
clamps `payload_hash` to exactly 32 bytes, so a raw Solana transaction cannot go through
the HTTP API (the NATS path has no clamp); and sr25519 (DOT/KSM) still has no keygen leg.
## 🔴 v1.17.12 — CRITICAL threshold-degree fix (was silently 1-of-n) + BRIDGE-ORACLE finding (2026-07-15)
**The bug (fixed by commit `1e1d318`, in tag `v1.17.12`):** `--threshold=N` only drove the
+27 -9
View File
@@ -27,6 +27,8 @@ import (
"github.com/mr-tron/base58"
"github.com/luxfi/mpc/pkg/address"
"github.com/nats-io/nats.go"
"github.com/spf13/viper"
"github.com/urfave/cli/v3"
@@ -825,8 +827,8 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
resp["evm_address"] = pubKeyToEVMAddress(result.ECDSAPubKey)
resp["btc_address"] = pubKeyToBtcAddress(result.ECDSAPubKey)
}
if len(result.EDDSAPubKey) == 32 {
resp["sol_address"] = eddsaPubKeyToSolAddress(result.EDDSAPubKey)
if solAddr := eddsaPubKeyToSolAddress(result.EDDSAPubKey); solAddr != "" {
resp["sol_address"] = solAddr
}
} else {
resp["error"] = result.ErrorReason
@@ -1199,9 +1201,7 @@ func (b *ConsensusMPCBackend) TriggerKeygen(orgID, walletID string) (*mpcapi.Key
evmAddr = pubKeyToEVMAddress(result.ECDSAPubKey)
btcAddr = pubKeyToBtcAddress(result.ECDSAPubKey)
}
if len(result.EDDSAPubKey) == 32 {
solAddr = eddsaPubKeyToSolAddress(result.EDDSAPubKey)
}
solAddr = eddsaPubKeyToSolAddress(result.EDDSAPubKey)
// Report the security property this key actually has. Keygen accepts no
// per-request threshold — the ring's --threshold governs — so a caller
// can only verify what it asked for if we tell it what it got.
@@ -1794,13 +1794,31 @@ func base58CheckEncode(version byte, payload []byte) string {
return base58.Encode(full)
}
// eddsaPubKeyToSolAddress derives a Solana address from an Ed25519 public key.
// The address is simply the base58 encoding of the 32-byte public key.
// eddsaPubKeyToSolAddress derives a Solana address from an Ed25519 public key,
// or returns "" when the bytes are not one.
//
// The check is delegated to pkg/address, which validates the curve rather than
// the length. Length alone cannot do this job: a BIP-340 x-only secp256k1 key is
// also 32 bytes and base58-encodes into an address that accepts deposits and can
// never be spent from. "" means the caller must omit sol_address entirely.
func eddsaPubKeyToSolAddress(pubKey []byte) string {
if len(pubKey) != 32 {
if len(pubKey) == 0 {
// No Ed25519 leg for this wallet. Ordinary and expected, so it is not
// worth a warning on every response.
return ""
}
return base58.Encode(pubKey)
addr, err := address.Solana(pubKey)
if err != nil {
// A key that is present but not an Ed25519 key is a different matter:
// something upstream produced the wrong curve, and that is worth
// shouting about even though it has been refused here.
logger.Warn("Refusing to derive a Solana address from a non-Ed25519 key",
"error", err.Error(),
"consequence", "sol_address omitted from the response",
)
return ""
}
return addr
}
// isThresholdLoopback reports whether `--threshold-listen` resolves to
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/mr-tron/base58"
"github.com/stretchr/testify/require"
)
// These tests sit at the daemon's own boundary: eddsaPubKeyToSolAddress is the
// function whose return value becomes the sol_address field of a /keygen
// response, which is the string a customer is told to deposit into.
// TestSolAddressFromRealEd25519Key checks the happy path still produces exactly
// the base58 of the key, so the change that added a curve check did not also
// change the encoding.
func TestSolAddressFromRealEd25519Key(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
addr := eddsaPubKeyToSolAddress(pub)
require.NotEmpty(t, addr)
decoded, err := base58.Decode(addr)
require.NoError(t, err)
require.Equal(t, []byte(pub), decoded)
}
// TestSolAddressRefusesSecp256k1Keys is the regression guard for the bug this
// change removes.
//
// A BIP-340 x-only secp256k1 public key is 32 bytes, so the length test that
// used to guard this call site accepted it and base58-encoded it into a
// well-formed Solana address. Deposits to that address were unrecoverable,
// because no Ed25519 signature can ever satisfy a secp256k1 key. The daemon must
// now return "" and omit the field.
func TestSolAddressRefusesSecp256k1Keys(t *testing.T) {
tested := 0
for attempt := 0; attempt < 512 && tested < 32; attempt++ {
candidate := make([]byte, 32)
_, err := rand.Read(candidate)
require.NoError(t, err)
// Only interested in bytes that are genuinely a secp256k1 x-only key —
// that is what makes this the real confusion and not just garbage.
if _, err := (curve.Secp256k1{}).LiftX(candidate); err != nil {
continue
}
// Skip the ~1-in-16 that also happen to be valid Ed25519 keys. Those are
// indistinguishable by inspection, which is exactly why provenance and
// not validation is the primary defence upstream of here.
if err := (curve.Ed25519{}).NewPoint().UnmarshalBinary(candidate); err == nil {
continue
}
tested++
require.Len(t, candidate, 32, "the premise: it passes any length test")
require.Empty(t, eddsaPubKeyToSolAddress(candidate),
"a secp256k1 x-only key must never produce a Solana address: %x", candidate)
}
require.GreaterOrEqual(t, tested, 32, "did not find enough secp256k1-only keys to test")
}
// TestSolAddressRefusesMissingKey covers the ordinary case: the Ed25519 keygen
// leg failed, so there is no key. The daemon must publish a wallet with no
// Solana address rather than invent one.
func TestSolAddressRefusesMissingKey(t *testing.T) {
for name, key := range map[string][]byte{
"nil": nil,
"empty": {},
"truncated": make([]byte, 31),
"oversized": make([]byte, 33),
} {
t.Run(name, func(t *testing.T) {
require.Empty(t, eddsaPubKeyToSolAddress(key))
})
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ require (
github.com/luxfi/lattice/v7 v7.1.4
github.com/luxfi/log v1.4.3
github.com/luxfi/metric v1.8.1
github.com/luxfi/threshold v1.12.5
github.com/luxfi/threshold v1.12.7
github.com/luxfi/zap v1.2.6
github.com/mitchellh/mapstructure v1.5.0
github.com/mr-tron/base58 v1.3.0
+2
View File
@@ -1626,6 +1626,8 @@ github.com/luxfi/threshold v1.12.4 h1:vfS3wdjVlLSu76B27EeX/Y0nMkmoXYezyPVlqvz/61
github.com/luxfi/threshold v1.12.4/go.mod h1:xQT8xzmh9pQ1CdBgpl7P3ezXQZcqTPv3tqpIytvuiTA=
github.com/luxfi/threshold v1.12.5 h1:0ouhE6XY8grrmLjnVW3KZEx2mK1pBxezhvvQar8GsfQ=
github.com/luxfi/threshold v1.12.5/go.mod h1:ddg3ipZ3WXozPn+TupPQPmNw/jGscmX13n9OtitmI6Q=
github.com/luxfi/threshold v1.12.7 h1:4Cg9/+kaFfbp3dcRfxD+G/nvRT+GLHQNyHmltwoDEII=
github.com/luxfi/threshold v1.12.7/go.mod h1:h5RAMAPRDHhqz8lG0LSfJUUFcjkBTWRmkVL421UPo5w=
github.com/luxfi/timer v1.1.1 h1:54GiNBKydQ0VF5/EwVc/mCsbqe0yJNfZV7Ae8qJhCwk=
github.com/luxfi/timer v1.1.1/go.mod h1:OXY/8ZFKCdEsimpfnUG1MQWvzjjFbsmBOiW/m6KSrC0=
github.com/luxfi/tls v1.1.1 h1:BSZ0gHSp7U8vzlmzx7WSSCz+b7Ky4JtD9HDDhn7vrDg=
+33 -5
View File
@@ -301,13 +301,11 @@ github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7Yzfgt
github.com/dgryski/go-ddmin v0.0.0-20210904190556-96a6d69f1034/go.mod h1:zz4KxBkcXUWKjIcrc+uphJ1gPh/t18ymGm3PmQ+VGTk=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0/go.mod h1:56wL82FO0bfMU5RvfXoIwSOP2ggqqxT+tAfNEIyxuHw=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
@@ -369,7 +367,6 @@ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/Nu
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
@@ -399,7 +396,6 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
@@ -428,6 +424,7 @@ github.com/guptarohit/asciigraph v0.5.5/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtX
github.com/hanzoai/dbx v1.13.0/go.mod h1:WIRkg1f2Mc+vXo2gpWk+hgPsJPFZBNUFCkSFJ4M5s4k=
github.com/hanzoai/kms/sdk/go v1.0.0/go.mod h1:kPPvEuE3K+fcOY+S6hiNO8nffxDNChvC1Uc4fR5Y+xk=
github.com/hanzoai/tygoja v0.0.2/go.mod h1:o+poSPLJ5S3SMtdz7gJHaL/6P6tjGBZcfQMl0SUIBr4=
github.com/hanzoai/vfs v0.4.1/go.mod h1:8AzyntfZgjK6iiB4A+xPestHgSAGUEHMthTkhACRIjM=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/go-bexpr v0.1.15/go.mod h1:HGKbAByHn2aJWUV47gL7+IjLK79iU3EZIbOwCXJZLoE=
@@ -756,6 +753,7 @@ github.com/luxfi/zap v0.0.0-20260126200933-22a2a4c72dfb/go.mod h1:uvmQO9kVvOfBcq
github.com/luxfi/zap v0.7.2/go.mod h1:1k+nwT+JW802YzuPAuf7CxMSGr/qxvbGgGwi5k6X9Ok=
github.com/luxfi/zapdb v1.0.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/luxfi/zapdb v1.9.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/luxfi/zapdb/v4 v4.9.1/go.mod h1:gYJjLcFCxmUATZ6Zg7iZHwAbgBMxGVP1g8UOWomK70Y=
github.com/luxfi/zapdb/v4 v4.9.3/go.mod h1:BrpLaDeYy+smON4ASDPCFKWrQ58CXhy3WNEPGna7vuU=
github.com/luxfi/zmq/v4 v4.2.2/go.mod h1:omy8ymZAhx60lx/nPsWT6uOV11uNFVnjtls+Hi5KqaU=
@@ -800,6 +798,7 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
@@ -858,6 +857,11 @@ github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pingcap/errors v0.11.5-0.20210425183316-da1aaba5fb63/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
@@ -881,7 +885,6 @@ github.com/protolambda/bls12-381-util v0.1.0/go.mod h1:cdkysJTRpeFeuUVx/TXGDQNMT
github.com/protolambda/messagediff v1.4.0/go.mod h1:LboJp0EwIbJsePYpzh5Op/9G1/4mIztMRYzzwR0dR2M=
github.com/protolambda/zrnt v0.34.1/go.mod h1:A0fezkp9Tt3GBLATSPIbuY4ywYESyAuc/FFmPKg8Lqs=
github.com/protolambda/ztyp v0.2.2/go.mod h1:9bYgKGqg3wJqT9ac1gI2hnVb0STQq7p/1lapqrqY1dU=
github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -933,6 +936,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
@@ -948,6 +952,8 @@ github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkC
github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
@@ -957,6 +963,7 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
@@ -982,6 +989,7 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
@@ -994,6 +1002,7 @@ go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FK
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
@@ -1005,6 +1014,7 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@@ -1024,8 +1034,11 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
@@ -1070,8 +1083,12 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
@@ -1117,8 +1134,12 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -1141,10 +1162,17 @@ golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+88
View File
@@ -0,0 +1,88 @@
// Package address turns a wallet's public keys into the deposit addresses a
// chain will accept.
//
// The rule this package exists to enforce is that an address is derived from a
// curve, never from a byte length. An Ed25519 public key and a BIP-340 x-only
// secp256k1 public key are both exactly 32 bytes, so a length test cannot tell
// them apart. Rendering a secp256k1 key as a Solana address produces a string
// that is syntactically perfect, accepts deposits, and can never be spent from,
// because no Ed25519 signature will ever satisfy it. Every function here
// therefore checks the key against its curve and returns an error instead of a
// string when it cannot. Callers must omit the address when they get an error:
// no address is a recoverable state, a wrong address is not.
//
// TON is deliberately absent. A TON wallet holds the same Ed25519 key this
// package validates, so the cryptography is not the gap — the address encoding
// is. A TON address is workchain ‖ SHA-256 of the StateInit cell of a specific
// wallet contract, rendered base64url with a CRC16, and it depends on which
// wallet version is deployed (v4r2 and W5 give different addresses for the same
// key). Producing it requires the TVM cell representation-hash construction and
// the exact contract code cell. Guessing any of that yields a fundable,
// unspendable address, which is the failure this package exists to prevent, so
// TON addresses are not emitted until that encoding is implemented against
// published test vectors.
package address
import (
"crypto/ed25519"
"errors"
"fmt"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/mr-tron/base58"
)
// ErrNotEd25519 reports that bytes offered as an Ed25519 public key are not one.
// Callers should treat it as "this wallet has no address on this chain" and emit
// nothing, never as a reason to fall back to encoding the bytes anyway.
var ErrNotEd25519 = errors.New("not an Ed25519 public key")
// Solana derives a Solana address from an Ed25519 public key.
//
// The address is the base58 encoding of the 32-byte key. base58 will encode any
// bytes at all, so the check happens first: the key must decode as a canonical
// edwards25519 point in the prime-order subgroup, using the same decoder the DKG
// uses to accept a key from the wire. A key that fails is refused.
//
// This check is defence in depth, not the primary defence. Against a wrong-curve
// key it is only probabilistic: roughly half of all 32-byte strings decode to
// some edwards25519 point, and one in eight of those lies in the prime-order
// subgroup, so a BIP-340 secp256k1 key slips through about one time in sixteen.
// The primary defence is provenance — the EdDSA public key is written only by
// the Ed25519 DKG, and nothing else is allowed to populate it.
func Solana(pub []byte) (string, error) {
if err := RequireEd25519(pub); err != nil {
return "", err
}
return base58.Encode(pub), nil
}
// RequireEd25519 reports whether pub is a usable Ed25519 public key: 32 bytes
// that decode to a canonical edwards25519 point of prime order, and that are not
// the identity.
//
// The prime-order requirement is not pedantry. Edwards25519 has cofactor 8, so a
// point can decode successfully and still carry a torsion component. The Schnorr
// verification equation S·B = R + k·A has a torsion-free left-hand side, so a
// torsion-tainted group key makes the equation unsatisfiable: the address would
// look ordinary, accept deposits, and never spend.
//
// The identity is excluded separately, because it passes the prime-order test —
// [L]O = O — while being far more dangerous than a torsion point. If the group
// key is the identity then verification reduces to S·B = R + k·O = R, which the
// pair (R = O, S = 0) satisfies for every message. Such an address is not merely
// unspendable by us; it is spendable by anyone who notices. The DKG does not
// itself reject an identity group key, so it is rejected here.
func RequireEd25519(pub []byte) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("%w: want %d bytes, got %d", ErrNotEd25519, ed25519.PublicKeySize, len(pub))
}
point := (curve.Ed25519{}).NewPoint()
if err := point.UnmarshalBinary(pub); err != nil {
return fmt.Errorf("%w: %v", ErrNotEd25519, err)
}
if point.IsIdentity() {
return fmt.Errorf("%w: key is the identity element, for which any signature verifies", ErrNotEd25519)
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package address
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/mr-tron/base58"
"github.com/stretchr/testify/require"
)
// TestSolanaRoundTripsARealEd25519Key pins the encoding itself: a Solana address
// is the base58 of the raw 32-byte key, nothing added and nothing hashed.
func TestSolanaRoundTripsARealEd25519Key(t *testing.T) {
for i := 0; i < 64; i++ {
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
addr, err := Solana(pub)
require.NoError(t, err, "a stdlib Ed25519 key must always yield an address")
decoded, err := base58.Decode(addr)
require.NoError(t, err)
require.Equal(t, []byte(pub), decoded, "the address must decode back to the key exactly")
}
}
// TestSolanaRefusesWrongLength covers truncation and padding, the failure modes
// that come from a mis-sized buffer rather than a wrong curve.
func TestSolanaRefusesWrongLength(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
for name, key := range map[string][]byte{
"nil": nil,
"empty": {},
"truncated": pub[:31],
"too_long": append([]byte(pub), 0x00),
"secp256k1_compressed_33_bytes": append([]byte{0x02}, pub...),
} {
t.Run(name, func(t *testing.T) {
addr, err := Solana(key)
require.ErrorIs(t, err, ErrNotEd25519)
require.Empty(t, addr, "an error must never be accompanied by an address")
})
}
}
// TestSolanaRefusesTorsionPoints is the subtle one. These decode as perfectly
// good curve points, so nothing short of a subgroup check rejects them — and a
// torsion-tainted key makes the Schnorr verification equation unsatisfiable, so
// the address would take deposits and never spend.
func TestSolanaRefusesTorsionPoints(t *testing.T) {
// Canonical small-order points of edwards25519 (RFC 8032 §5.1.7 / the
// standard small-order set): identity, the order-2 point, and the two
// order-4 points.
smallOrder := map[string]string{
"identity": "0100000000000000000000000000000000000000000000000000000000000000",
"order_2": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
"order_4_a": "0000000000000000000000000000000000000000000000000000000000000080",
"order_4_b": "0000000000000000000000000000000000000000000000000000000000000000",
"order_8_mix1": "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
"order_8_mix2": "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
}
for name, hexKey := range smallOrder {
t.Run(name, func(t *testing.T) {
key := mustHex(t, hexKey)
// Every one of these must be refused. The identity is included
// deliberately: it passes the prime-order test ([L]O = O) and is the
// worst case of the set, because a wallet keyed to it is spendable
// by anybody rather than by nobody.
addr, err := Solana(key)
require.Error(t, err, "small-order point %s must never yield an address", name)
require.ErrorIs(t, err, ErrNotEd25519)
require.Empty(t, addr, "an error must never be accompanied by an address")
})
}
}
// TestRequireEd25519AgreesWithTheDKGDecoder makes the address gate and the
// ceremony gate the same gate. If they ever diverge, a key could be good enough
// to mint an address but not good enough to sign with, or the reverse.
func TestRequireEd25519AgreesWithTheDKGDecoder(t *testing.T) {
for i := 0; i < 256; i++ {
candidate := make([]byte, 32)
_, err := rand.Read(candidate)
require.NoError(t, err)
addrErr := RequireEd25519(candidate)
point := (curve.Ed25519{}).NewPoint()
dkgErr := point.UnmarshalBinary(candidate)
if dkgErr == nil && point.IsIdentity() {
// The one intended divergence: the DKG decoder accepts the identity,
// the address gate does not. Asserted rather than tolerated so the
// difference stays deliberate.
require.Error(t, addrErr, "identity must be refused by the address gate")
continue
}
require.Equal(t, dkgErr == nil, addrErr == nil,
"address gate and DKG decoder disagree on %x", candidate)
}
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
out := make([]byte, len(s)/2)
for i := 0; i < len(out); i++ {
var b int
for j := 0; j < 2; j++ {
c := s[i*2+j]
var v int
switch {
case c >= '0' && c <= '9':
v = int(c - '0')
case c >= 'a' && c <= 'f':
v = int(c-'a') + 10
default:
t.Fatalf("bad hex %q", s)
}
b = b<<4 | v
}
out[i] = byte(b)
}
return out
}
+11
View File
@@ -114,6 +114,17 @@ func (s *Server) handleKMSGenerate(w http.ResponseWriter, r *http.Request) {
return
}
// An empty EdDSA key is now a normal keygen outcome, not an impossibility:
// the Ed25519 leg is deliberately non-fatal, so a wallet can come back with
// no key at all. Storing that as an "active" key set would register a
// validator whose key is the empty string — recorded as usable, usable by
// nobody. Refuse it here rather than persist it.
if len(coronaResult.EDDSAPubKey) == 0 {
writeError(w, http.StatusInternalServerError,
"keygen returned no EdDSA public key; refusing to register a key set without one")
return
}
now := time.Now().UTC()
ks := &ValidatorKeySet{
ValidatorID: req.ValidatorID,
+6 -1
View File
@@ -152,7 +152,12 @@ func (s *Server) handleGetWalletAddresses(w http.ResponseWriter, r *http.Request
}
if wallet.SolAddress != nil {
addresses["solana"] = *wallet.SolAddress
addresses["ton"] = *wallet.SolAddress // Ed25519-based
// No "ton" entry. TON shares this wallet's Ed25519 key but not its
// address format: a TON address is workchain ‖ SHA-256 of a wallet
// contract's StateInit cell, base64url with a CRC16, and it differs
// between contract versions. The Solana base58 string is not a TON
// address in any encoding, so serving it here published an address that
// can receive nothing and prove nothing. Absent until address.TON exists.
}
writeJSON(w, http.StatusOK, addresses)
}
+102 -6
View File
@@ -30,6 +30,12 @@ const (
DefaultSessionStartupDelay = 500
KeyGenTimeOut = 120 * time.Second
// Ed25519KeyGenTimeOut bounds the Ed25519 leg on its own, so the expensive
// secp256k1 leg cannot starve it. FROST keygen is three rounds with no heavy
// primitives — it is bounded by network round-trips, not computation — so
// this is generous rather than tight.
Ed25519KeyGenTimeOut = 60 * time.Second
)
type EventConsumer interface {
@@ -119,6 +125,45 @@ func (ec *eventConsumer) Run() {
logger.Info("MPC Event consumer started...!")
}
// handleKeyGenEvent mints a wallet's key set and publishes it.
//
// A wallet is not one key. Chains disagree about curves, and
// types.GetNetworkKeyType is the single table recording which chain wants which,
// so a wallet needs one key per curve its networks use and a single keygen
// request mints them all. This function is the only place that decides which
// ceremonies run, which is what keeps every site downstream from having to know
// or guess a curve.
//
// The two legs do not have equal standing:
//
// - secp256k1 (CGGMP21) backs EVM, Bitcoin, LUX and XRPL. Its failure fails the
// wallet.
// - Ed25519 (FROST) backs Solana and TON. Its failure is deliberately NOT
// fatal: the wallet is published without eddsa_pub_key, which suppresses
// sol_address downstream. A wallet that cannot receive Solana is an
// inconvenience; a wallet that receives Solana at an address the ring cannot
// sign for has lost the funds permanently. Only the first is recoverable, so
// the Ed25519 leg fails closed and the wallet survives without it.
//
// sr25519 (Polkadot/Kusama) appears in the network table but has no leg here, so
// DOT/KSM wallets still cannot be minted. That gap predates this function and is
// left visible rather than papered over.
//
// The order is load-bearing and must not be changed or parallelised. Only the
// CGGMP21 session writes the wallet's keyinfo record (its threshold and
// participant set); the Ed25519 session never does. CreateEdDSASignSession reads
// that record to decide whether it has enough signers, so an Ed25519 key minted
// without the CGGMP21 leg having completed first would be unsignable — the exact
// outcome this whole change exists to prevent. Running secp256k1 first, and
// abandoning the wallet if it fails, is what guarantees the record exists.
//
// Each leg gets its OWN deadline rather than sharing one. A single budget across
// two sequential ceremonies is not shared fairly: CGGMP21 is the expensive leg
// (Paillier keygen and its proofs), so it would routinely consume most of the
// budget and starve the Ed25519 leg into a systematic timeout — turning "Solana
// is supported" into "Solana works when the ring is idle". Separate deadlines
// also mean the secp256k1 leg keeps exactly the budget it had before this
// change, so nothing that worked previously became slower or less reliable.
func (ec *eventConsumer) handleKeyGenEvent(natMsg *nats.Msg) {
raw := natMsg.Data
var msg types.GenerateKeyMessage
@@ -134,13 +179,64 @@ func (ec *eventConsumer) handleKeyGenEvent(natMsg *nats.Msg) {
return
}
// Convert to event message format and use CGGMP21 handler
eventMsg := &event.Message{
OrgID: msg.OrgID,
EventType: MPCGenerateEvent,
WalletID: msg.WalletID,
ec.trackSession(msg.OrgID, msg.WalletID, "")
defer ec.untrackSession(msg.OrgID, msg.WalletID, "")
result := &event.KeygenResultEvent{
WalletID: msg.WalletID,
ResultType: event.ResultTypeSuccess,
}
ec.handleKeyGenEventCGGMP21(eventMsg, natMsg)
secpCtx, cancelSecp := context.WithTimeout(context.Background(), KeyGenTimeOut)
ecdsaPubKey, err := ec.runCGGMP21Keygen(secpCtx, msg.OrgID, msg.WalletID)
cancelSecp()
if err != nil {
ec.handleKeygenSessionError(msg.WalletID, err, "CGGMP21 key generation failed", natMsg)
return
}
result.ECDSAPubKey = ecdsaPubKey
ed25519Ctx, cancelEd := context.WithTimeout(context.Background(), Ed25519KeyGenTimeOut)
eddsaPubKey, err := ec.runEd25519Keygen(ed25519Ctx, msg.OrgID, msg.WalletID)
cancelEd()
if err != nil {
logEd25519LegSkipped(msg.WalletID, err)
} else {
result.EDDSAPubKey = eddsaPubKey
}
// Nothing below this line may relax the curve check. If an EdDSA key is
// present it must be a real Ed25519 key; otherwise drop it and publish the
// wallet without one.
if err := assertKeygenResultConsistent(result); err != nil {
logEd25519LegSkipped(msg.WalletID, err)
result.EDDSAPubKey = nil
}
payload, err := json.Marshal(result)
if err != nil {
logger.Error("Failed to marshal keygen success event", err)
ec.handleKeygenSessionError(msg.WalletID, err, "Failed to marshal keygen success event", natMsg)
return
}
key := fmt.Sprintf(mpc.TypeGenerateWalletResultFmt, msg.WalletID)
if err := ec.genKeyResultQueue.Enqueue(
key,
payload,
&messaging.EnqueueOptions{IdempotententKey: composeKeygenIdempotentKey(msg.WalletID, natMsg)},
); err != nil {
logger.Error("Failed to publish key generation success message", err)
ec.handleKeygenSessionError(msg.WalletID, err, "Failed to publish key generation success message", natMsg)
return
}
ec.sendReplyToRemoveMsg(natMsg)
logger.Info("[COMPLETED KEY GEN] key generation completed",
"walletID", msg.WalletID,
"hasECDSAKey", len(result.ECDSAPubKey) > 0,
"hasEd25519Key", len(result.EDDSAPubKey) > 0,
)
}
// handleKeygenSessionError handles errors that occur during key generation
+35 -87
View File
@@ -3,131 +3,79 @@ package eventconsumer
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/logger"
"github.com/luxfi/mpc/pkg/messaging"
"github.com/luxfi/mpc/pkg/mpc"
)
// handleKeyGenEventCGGMP21 handles key generation events for CGGMP21 protocol
func (ec *eventConsumer) handleKeyGenEventCGGMP21(msg *event.Message, natMsg *nats.Msg) {
// Mark session as active
ec.trackSession(msg.OrgID, msg.WalletID, "")
// Remove session from active list when done
defer ec.untrackSession(msg.OrgID, msg.WalletID, "")
// Create a context with timeout for the entire key generation process
baseCtx, cancel := context.WithTimeout(context.Background(), KeyGenTimeOut)
defer cancel()
// Decode the message
if msg.EventType != MPCGenerateEvent {
logger.Error("unexpected event type", nil, "expected", MPCGenerateEvent, "got", msg.EventType)
return
}
walletID := msg.WalletID
// Create CGGMP21 keygen session
keygenSession, err := ec.node.CreateKeyGenSession(walletID, ec.mpcThreshold, ec.genKeyResultQueue, msg.OrgID)
// runCGGMP21Keygen runs the CGGMP21 ceremony and returns the wallet's secp256k1
// public key: the key behind its EVM, Bitcoin, LUX and XRPL addresses.
//
// It publishes nothing. Running a ceremony and announcing a wallet are separate
// jobs — a wallet's key set has more than one leg, and only the orchestrator can
// see whether the set as a whole is worth announcing.
func (ec *eventConsumer) runCGGMP21Keygen(ctx context.Context, orgID, walletID string) ([]byte, error) {
keygenSession, err := ec.node.CreateKeyGenSession(walletID, ec.mpcThreshold, ec.genKeyResultQueue, orgID)
if err != nil {
ec.handleKeygenSessionError(walletID, err, "Failed to create CGGMP21 key generation session", natMsg)
return
return nil, fmt.Errorf("failed to create CGGMP21 key generation session: %w", err)
}
keygenSession.Init()
// Setup context for monitoring
ctx, done := context.WithCancel(baseCtx)
monitorCtx, done := context.WithCancel(ctx)
defer done()
// Prepare success event
successEvent := &event.KeygenResultEvent{
WalletID: walletID,
ResultType: event.ResultTypeSuccess,
}
// Channel to communicate errors
errorChan := make(chan error, 1)
// Monitor for errors in background
go func() {
select {
case <-ctx.Done():
case <-monitorCtx.Done():
return
case err := <-keygenSession.ErrChan():
if err != nil {
logger.Error("CGGMP21 keygen session error", err)
errorChan <- err
done()
select {
case errorChan <- err:
default:
}
}
}
}()
// Start listening to messages
keygenSession.ListenToIncomingMessageAsync()
// Small delay for peer setup
time.Sleep(DefaultSessionStartupDelay * time.Millisecond)
// Start processing outbound messages
go keygenSession.ProcessOutboundMessage()
// Wait for the keygen to complete
completionChan := make(chan string, 1)
go func() {
result := keygenSession.WaitForFinish()
completionChan <- result
completionChan <- keygenSession.WaitForFinish()
}()
// Wait for completion, error, or timeout
select {
case pubKeyHex := <-completionChan:
// Success - set the public key
if pubKeyHex != "" {
pubKeyBytes, err := hex.DecodeString(pubKeyHex)
if err == nil {
successEvent.ECDSAPubKey = pubKeyBytes
}
if pubKeyHex == "" {
// The session sends "" for every failure it can detect, including a
// failed kvstore.Put and a failed keyinfo save. Treating that as
// success — which this handler used to do — announces a wallet whose
// EVM and Bitcoin addresses are published while fewer than t+1 nodes
// actually hold a share, so the addresses can receive funds and can
// never sign. That is the same shape of loss this change removes on
// the Solana side, and the same rule applies: under uncertainty,
// emit nothing.
return nil, fmt.Errorf("CGGMP21 keygen produced no public key")
}
done() // Signal completion
pubKeyBytes, err := hex.DecodeString(pubKeyHex)
if err != nil {
return nil, fmt.Errorf("CGGMP21 keygen returned a malformed public key: %w", err)
}
return pubKeyBytes, nil
case err := <-errorChan:
// Error occurred
ec.handleKeygenSessionError(walletID, err, "CGGMP21 keygen error", natMsg)
return
return nil, fmt.Errorf("CGGMP21 keygen error: %w", err)
case <-baseCtx.Done():
// Timeout occurred
logger.Warn("Key generation timed out", "walletID", walletID, "timeout", KeyGenTimeOut)
ec.handleKeygenSessionError(walletID, fmt.Errorf("keygen session timed out after %v", KeyGenTimeOut), "Key generation timed out", natMsg)
return
case <-ctx.Done():
return nil, fmt.Errorf("keygen session timed out after %v", KeyGenTimeOut)
}
// Marshal and publish success event
payload, err := json.Marshal(successEvent)
if err != nil {
logger.Error("Failed to marshal keygen success event", err)
ec.handleKeygenSessionError(walletID, err, "Failed to marshal keygen success event", natMsg)
return
}
key := fmt.Sprintf(mpc.TypeGenerateWalletResultFmt, walletID)
if err := ec.genKeyResultQueue.Enqueue(
key,
payload,
&messaging.EnqueueOptions{IdempotententKey: composeKeygenIdempotentKey(walletID, natMsg)},
); err != nil {
logger.Error("Failed to publish key generation success message", err)
ec.handleKeygenSessionError(walletID, err, "Failed to publish key generation success message", natMsg)
return
}
ec.sendReplyToRemoveMsg(natMsg)
logger.Info("[COMPLETED KEY GEN] CGGMP21 key generation completed successfully", "walletID", walletID)
}
+115
View File
@@ -0,0 +1,115 @@
package eventconsumer
import (
"context"
"encoding/hex"
"fmt"
"time"
"github.com/luxfi/mpc/pkg/address"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/logger"
)
// runEd25519Keygen runs FROST distributed key generation over edwards25519 and
// returns the wallet's 32-byte Ed25519 public key.
//
// This is the only function in the daemon permitted to produce that key. The key
// it returns is what becomes a Solana address, so its provenance is the whole
// security property: FROST over secp256k1 (KeygenTaproot) also yields 32 bytes,
// and those 32 bytes base58-encode into an address that looks perfect, accepts
// deposits, and can never be spent from. The ceremony is pinned to
// frost.KeygenEd25519 inside the session, and the result is checked against the
// curve here before it is allowed out.
//
// It returns an error rather than a key whenever anything is uncertain. The
// caller is expected to publish a wallet with no eddsa_pub_key in that case,
// which is the system correctly declining to name an address it cannot sign for.
//
// The shape mirrors runCGGMP21Keygen: run the ceremony, return the key, publish
// nothing. Whether the wallet as a whole succeeded is the orchestrator's call.
func (ec *eventConsumer) runEd25519Keygen(ctx context.Context, orgID, walletID string) ([]byte, error) {
session, err := ec.node.CreateEdDSAKeyGenSession(walletID, ec.mpcThreshold, ec.genKeyResultQueue, orgID)
if err != nil {
return nil, fmt.Errorf("failed to create Ed25519 keygen session: %w", err)
}
session.Init()
monitorCtx, done := context.WithCancel(ctx)
defer done()
errorChan := make(chan error, 1)
go func() {
select {
case <-monitorCtx.Done():
return
case err := <-session.ErrChan():
if err != nil {
select {
case errorChan <- err:
default:
}
}
}
}()
session.ListenToIncomingMessageAsync()
// Small delay for peer setup, matching the CGGMP21 leg.
time.Sleep(DefaultSessionStartupDelay * time.Millisecond)
go session.ProcessOutboundMessage()
completionChan := make(chan string, 1)
go func() {
completionChan <- session.WaitForFinish()
}()
select {
case pubKeyHex := <-completionChan:
// The session sends "" for every failure it can detect, so an empty
// string is a failed ceremony, not an empty key.
if pubKeyHex == "" {
return nil, fmt.Errorf("Ed25519 keygen produced no public key")
}
pubKey, err := hex.DecodeString(pubKeyHex)
if err != nil {
return nil, fmt.Errorf("Ed25519 keygen returned malformed public key: %w", err)
}
// Last gate before these bytes can become an address: they must decode
// as a prime-order edwards25519 point. Provenance above is the real
// defence; this catches a truncated, corrupted, or wrong-curve key.
if err := address.RequireEd25519(pubKey); err != nil {
return nil, fmt.Errorf("Ed25519 keygen returned a key that is not on the curve: %w", err)
}
return pubKey, nil
case err := <-errorChan:
return nil, fmt.Errorf("Ed25519 keygen error: %w", err)
case <-ctx.Done():
return nil, fmt.Errorf("Ed25519 keygen timed out after %v", KeyGenTimeOut)
}
}
// logEd25519LegSkipped records that a wallet was minted without a Solana
// address, so the absence is visible in logs rather than inferred from a missing
// JSON field.
func logEd25519LegSkipped(walletID string, err error) {
logger.Warn("Ed25519 keygen leg failed; wallet has no Solana address",
"walletID", walletID,
"error", err.Error(),
"consequence", "eddsa_pub_key and sol_address will be absent from the keygen result",
)
}
// assertKeygenResultConsistent is a belt-and-braces check run immediately before
// the wallet's result is published: if an EdDSA key is present at all, it must be
// a real Ed25519 key. It exists so that any future path that learns to populate
// EDDSAPubKey inherits the curve check without having to remember it.
func assertKeygenResultConsistent(result *event.KeygenResultEvent) error {
if len(result.EDDSAPubKey) == 0 {
return nil
}
return address.RequireEd25519(result.EDDSAPubKey)
}
@@ -0,0 +1,81 @@
package eventconsumer
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/stretchr/testify/require"
"github.com/luxfi/mpc/pkg/event"
)
// assertKeygenResultConsistent is the last gate before a wallet is announced.
// These tests pin the two states it must allow and the one it must not.
// TestKeygenResultAllowsAbsentEd25519Key is the fail-closed case. A wallet whose
// Ed25519 leg failed is a good wallet with no Solana address, and publishing it
// must not be blocked — otherwise a Solana outage would stop EVM wallets from
// being minted.
func TestKeygenResultAllowsAbsentEd25519Key(t *testing.T) {
for name, result := range map[string]*event.KeygenResultEvent{
"nil_eddsa_key": {WalletID: "w1", EDDSAPubKey: nil},
"empty_eddsa_key": {WalletID: "w2", EDDSAPubKey: []byte{}},
} {
t.Run(name, func(t *testing.T) {
require.NoError(t, assertKeygenResultConsistent(result))
})
}
}
// TestKeygenResultAllowsRealEd25519Key checks the gate does not reject the keys
// it exists to protect.
func TestKeygenResultAllowsRealEd25519Key(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
require.NoError(t, assertKeygenResultConsistent(&event.KeygenResultEvent{
WalletID: "w3",
EDDSAPubKey: pub,
}))
}
// TestKeygenResultRejectsWrongCurveKey is the guard: if an EdDSA key is present
// at all, it must be a real Ed25519 key. A secp256k1 x-only key is 32 bytes and
// would otherwise sail through to become a Solana address.
func TestKeygenResultRejectsWrongCurveKey(t *testing.T) {
tested := 0
for attempt := 0; attempt < 512 && tested < 16; attempt++ {
candidate := make([]byte, 32)
_, err := rand.Read(candidate)
require.NoError(t, err)
if _, err := (curve.Secp256k1{}).LiftX(candidate); err != nil {
continue
}
if err := (curve.Ed25519{}).NewPoint().UnmarshalBinary(candidate); err == nil {
continue
}
tested++
require.Error(t, assertKeygenResultConsistent(&event.KeygenResultEvent{
WalletID: "w4",
EDDSAPubKey: candidate,
}), "a secp256k1 key must never be publishable as an EdDSA key: %x", candidate)
}
require.GreaterOrEqual(t, tested, 16, "did not find enough secp256k1-only keys to test")
}
// TestKeygenResultRejectsTruncatedKey covers corruption in transit or storage:
// anything that is not exactly a valid key must stop here rather than become an
// address.
func TestKeygenResultRejectsTruncatedKey(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
require.Error(t, assertKeygenResultConsistent(&event.KeygenResultEvent{
WalletID: "w5",
EDDSAPubKey: pub[:31],
}))
}
+114
View File
@@ -0,0 +1,114 @@
package mpc
import (
"context"
"sync"
"testing"
"time"
log "github.com/luxfi/log"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/stretchr/testify/require"
)
// A shared in-process harness for driving real threshold ceremonies in tests.
//
// The point of running the ceremony rather than stubbing it is that the failures
// worth catching here are cryptographic, not structural: a key on the wrong
// curve, a signature encoded the wrong way round, a share that does not survive
// storage. None of those are visible unless the real protocol runs and the real
// bytes come out.
// ceremonyTimeout bounds a whole in-process ceremony so a stalled protocol fails
// the test in seconds instead of hanging on the library's 5m default.
const ceremonyTimeout = 90 * time.Second
// ceremonyParties is a 2-of-3 committee: the smallest shape where a signing
// subset is a strict subset of the keygen set, so Lagrange interpolation is
// genuinely exercised rather than degenerating into a single share.
func ceremonyParties() []party.ID {
return []party.ID{"node-a", "node-b", "node-c"}
}
// runCeremony drives a threshold protocol to completion entirely in-process:
// every party gets its own protocol.Handler and messages are relayed directly
// between them.
//
// This is the same StartFunc the daemon's sessions hand to protocol.NewHandler,
// so the cryptographic output is the production output — only NATS, the kvstore,
// and the peer registry are absent.
func runCeremony(
t *testing.T,
ids []party.ID,
sessionID []byte,
start func(party.ID) protocol.StartFunc,
) map[party.ID]any {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), ceremonyTimeout)
defer cancel()
inbox := make(map[party.ID]chan *protocol.Message, len(ids))
for _, id := range ids {
inbox[id] = make(chan *protocol.Message, 256)
}
handlers := make(map[party.ID]*protocol.Handler, len(ids))
for _, id := range ids {
h, err := protocol.NewHandler(
ctx,
log.NewTestLogger(log.ErrorLevel),
nil,
start(id),
sessionID,
protocol.DefaultConfig(),
)
require.NoError(t, err, "party %s failed to start", id)
handlers[id] = h
}
var (
mu sync.Mutex
results = make(map[party.ID]any, len(ids))
errs = make(map[party.ID]error, len(ids))
wg sync.WaitGroup
)
for id, h := range handlers {
wg.Add(1)
go func(id party.ID, h *protocol.Handler) {
defer wg.Done()
for {
select {
case msg, ok := <-h.Listen():
if !ok {
res, err := h.Result()
mu.Lock()
results[id], errs[id] = res, err
mu.Unlock()
return
}
for other, ch := range inbox {
if other == msg.From {
continue
}
if msg.To != "" && msg.To != other {
continue
}
ch <- msg
}
case msg := <-inbox[id]:
h.Accept(msg)
}
}
}(id, h)
}
wg.Wait()
for _, id := range ids {
require.NoError(t, errs[id], "party %s did not finish the ceremony", id)
require.NotNil(t, results[id], "party %s produced no result", id)
}
return results
}
+132
View File
@@ -0,0 +1,132 @@
package mpc
import (
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/frost"
)
// Ed25519ShareKey is the kvstore key holding a wallet's Ed25519 FROST share.
//
// Keygen writes it and signing reads it, so it is defined once: two spellings of
// this prefix would mean a wallet whose key exists but cannot be found, which
// presents as an address that takes deposits and never signs.
//
// The prefix is "ed25519:" rather than "frost:" because FROST is a scheme
// family, not a group. Shares written under the old "frost:" prefix were BIP-340
// over secp256k1 and can never satisfy an Ed25519 verifier, so leaving them
// unreachable is the intent, not a migration gap.
func Ed25519ShareKey(walletID string) string {
return fmt.Sprintf("ed25519:%s", walletID)
}
// ed25519ConfigMarshal is the CBOR-friendly representation of a FROST Config
// over edwards25519. Every field is a fixed-width encoding of a crypto type,
// which is why this cannot be JSON: the curve types have no JSON marshalers and
// silently round-trip to nothing.
type ed25519ConfigMarshal struct {
ID party.ID
Threshold int
PrivateShare []byte // 32 bytes (Ed25519Scalar)
PublicKey []byte // 32 bytes (Ed25519Point, RFC 8032 encoding)
ChainKey []byte // 32 bytes
VerificationShares []ed25519VerificationShareMarshal
}
type ed25519VerificationShareMarshal struct {
ID party.ID
Point []byte // 32 bytes (Ed25519Point)
}
// MarshalEd25519Config serializes a FROST Config over edwards25519 to CBOR.
func MarshalEd25519Config(config *frost.Config) ([]byte, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
privateShareBytes, err := config.PrivateShare.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal private share: %w", err)
}
publicKeyBytes, err := config.PublicKey.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal public key: %w", err)
}
shares := make([]ed25519VerificationShareMarshal, 0, len(config.VerificationShares.Points))
for id, point := range config.VerificationShares.Points {
pointBytes, err := point.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal verification share for %s: %w", id, err)
}
shares = append(shares, ed25519VerificationShareMarshal{
ID: id,
Point: pointBytes,
})
}
cm := &ed25519ConfigMarshal{
ID: config.ID,
Threshold: config.Threshold,
PrivateShare: privateShareBytes,
PublicKey: publicKeyBytes,
ChainKey: config.ChainKey,
VerificationShares: shares,
}
return cbor.Marshal(cm)
}
// UnmarshalEd25519Config deserializes CBOR bytes to a FROST Config over
// edwards25519.
//
// Every point goes through curve.Ed25519's decoder, which rejects anything that
// is not a canonical point in the prime-order subgroup, so this is a curve check
// and not merely a parse.
//
// It is not a proof of provenance, and should not be described as one. A point
// from another curve can coincidentally be a valid edwards25519 point — for a
// 32-byte encoding that happens about one time in sixteen — so a foreign share
// is rejected with overwhelming but not certain probability. What actually keeps
// a wrong-curve share out is that only the Ed25519 ceremony ever writes to this
// key, and the ceremony is pinned by type, not by inspection.
func UnmarshalEd25519Config(data []byte) (*frost.Config, error) {
cm := &ed25519ConfigMarshal{}
if err := cbor.Unmarshal(data, cm); err != nil {
return nil, fmt.Errorf("failed to unmarshal Ed25519 config: %w", err)
}
group := curve.Ed25519{}
privateShare := group.NewScalar()
if err := privateShare.UnmarshalBinary(cm.PrivateShare); err != nil {
return nil, fmt.Errorf("failed to unmarshal private share: %w", err)
}
publicKey := group.NewPoint()
if err := publicKey.UnmarshalBinary(cm.PublicKey); err != nil {
return nil, fmt.Errorf("failed to unmarshal public key: %w", err)
}
verificationShares := make(map[party.ID]curve.Point, len(cm.VerificationShares))
for _, vs := range cm.VerificationShares {
point := group.NewPoint()
if err := point.UnmarshalBinary(vs.Point); err != nil {
return nil, fmt.Errorf("failed to unmarshal verification share for %s: %w", vs.ID, err)
}
verificationShares[vs.ID] = point
}
return &frost.Config{
ID: cm.ID,
Threshold: cm.Threshold,
PrivateShare: privateShare,
PublicKey: publicKey,
ChainKey: cm.ChainKey,
VerificationShares: party.NewPointMap(verificationShares),
}, nil
}
+131
View File
@@ -0,0 +1,131 @@
package mpc
import (
"crypto/ed25519"
"fmt"
"testing"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/protocols/frost"
"github.com/stretchr/testify/require"
)
// mapKVStore is the smallest thing satisfying kvstore.KVStore, so the real
// org-scoping and key-naming code can run against it.
type mapKVStore map[string][]byte
func (m mapKVStore) Put(key string, value []byte) error { m[key] = value; return nil }
func (m mapKVStore) Delete(key string) error { delete(m, key); return nil }
func (m mapKVStore) Close() error { return nil }
func (m mapKVStore) Backup() error { return nil }
func (m mapKVStore) Get(key string) ([]byte, error) {
v, ok := m[key]
if !ok {
return nil, fmt.Errorf("key not found: %s", key)
}
return v, nil
}
// TestEd25519ShareSurvivesTheRealStorageSeam closes the gap between "the codec
// round-trips" and "signing can find what keygen wrote".
//
// The two halves compute their storage key independently — keygen in
// publishResult, signing in newFROSTSigningSession — and a wallet whose share is
// written to one key and read from another is a wallet with a perfectly good
// address that can never spend. This exercises the real Ed25519ShareKey and the
// real org-scoping on both sides.
func TestEd25519ShareSurvivesTheRealStorageSeam(t *testing.T) {
ids := ceremonyParties()
const (
threshold = 1
orgID = "org-with:colons"
walletID = "wallet-abc123"
)
raw := runCeremony(t, ids, []byte("storage-seam"), func(id party.ID) protocol.StartFunc {
return ed25519KeygenStart(id, ids, threshold)
})
store := mapKVStore{}
// WRITE — exactly as frostKeygenSession.publishResult does. Checked, not a
// bare assertion: a wrong type here means the daemon ran a different curve,
// which must fail this test rather than panic and abort the whole package.
original, ok := raw[ids[0]].(*frost.Config)
require.True(t, ok, "keygen must produce *frost.Config, got %T", raw[ids[0]])
shareBytes, err := MarshalEd25519Config(original)
require.NoError(t, err)
require.NoError(t, store.Put(OrgScopedKey(orgID, Ed25519ShareKey(walletID)), shareBytes))
// READ — exactly as newFROSTSigningSession does.
loadedBytes, err := GetKeyShareWithFallback(store, orgID, Ed25519ShareKey(walletID))
require.NoError(t, err, "signing must find the share keygen wrote")
loaded, err := UnmarshalEd25519Config(loadedBytes)
require.NoError(t, err)
// The reloaded share must still sign for the same address.
originalPub, err := original.PublicKey.MarshalBinary()
require.NoError(t, err)
loadedPub, err := loaded.PublicKey.MarshalBinary()
require.NoError(t, err)
require.Equal(t, originalPub, loadedPub, "storage must not change the wallet key")
require.Equal(t, original.Threshold, loaded.Threshold)
require.Equal(t, original.ID, loaded.ID)
// And prove it end-to-end: sign with configs that all came back out of the
// store, then verify against the address those bytes encode.
configs := make(map[party.ID]*frost.Config, len(ids))
for id, res := range raw {
produced, ok := res.(*frost.Config)
require.True(t, ok, "party %s: want *frost.Config, got %T", id, res)
blob, err := MarshalEd25519Config(produced)
require.NoError(t, err)
require.NoError(t, store.Put(OrgScopedKey(orgID, Ed25519ShareKey(walletID)+":"+string(id)), blob))
back, err := GetKeyShareWithFallback(store, orgID, Ed25519ShareKey(walletID)+":"+string(id))
require.NoError(t, err)
cfg, err := UnmarshalEd25519Config(back)
require.NoError(t, err)
configs[id] = cfg
}
signers := ids[:threshold+1]
message := []byte("spend from a wallet reloaded entirely from storage")
rawSign := runCeremony(t, signers, []byte("storage-seam-sign"), func(id party.ID) protocol.StartFunc {
return ed25519SignStart(configs[id], signers, message)
})
sig, ok := rawSign[signers[0]].(frost.Ed25519Signature)
require.True(t, ok, "want frost.Ed25519Signature, got %T", rawSign[signers[0]])
encoded, err := sig.MarshalBinary()
require.NoError(t, err)
require.True(t, ed25519.Verify(ed25519.PublicKey(originalPub), message, encoded),
"shares reloaded from the kvstore must still sign for the wallet address")
}
// TestEd25519ShareIsNotReachableUnderTheLegacyPrefix pins the deliberate break.
// Shares written under "frost:" were BIP-340 over secp256k1 and can never
// satisfy an Ed25519 verifier, so they must stay unreachable rather than be
// migrated into a path that would treat them as spendable.
func TestEd25519ShareIsNotReachableUnderTheLegacyPrefix(t *testing.T) {
const (
orgID = "org1"
walletID = "w1"
)
store := mapKVStore{}
store.Put(OrgScopedKey(orgID, "frost:"+walletID), []byte("legacy taproot share"))
_, err := GetKeyShareWithFallback(store, orgID, Ed25519ShareKey(walletID))
require.Error(t, err, "an Ed25519 lookup must not fall back to a legacy secp256k1 share")
}
// TestKeyShareLookupRequiresAnOrg guards the org boundary: an empty org must not
// silently read some other tenant's share.
func TestKeyShareLookupRequiresAnOrg(t *testing.T) {
store := mapKVStore{}
store.Put(Ed25519ShareKey("w1"), []byte("unscoped"))
_, err := GetKeyShareWithFallback(store, "", Ed25519ShareKey("w1"))
require.Error(t, err, "unscoped key access must be refused")
}
+161
View File
@@ -0,0 +1,161 @@
package mpc
import (
"bytes"
"crypto/sha256"
"testing"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/protocols/frost"
"github.com/stretchr/testify/require"
)
// These tests construct the production signing session itself, rather than a
// mirror of it.
//
// That distinction is the whole point. A test that calls frost.SignEd25519
// directly proves the library is correct; it proves nothing about what the
// daemon hands the library. The original bug survived its tests precisely
// because the tests and the code agreed on the word "EdDSA" and disagreed about
// everything underneath it.
// newTestEd25519Session runs a real ceremony, stores a real share, and builds
// the real session over it. pubSub, keyinfo, resultQueue and identity are nil:
// newFROSTSigningSession only reads the kvstore and prepares state, so nil is
// enough to exercise everything that happens before the protocol starts — which
// is where message handling is decided.
func newTestEd25519Session(t *testing.T, message []byte) (*frostSigningSession, *frost.Config) {
t.Helper()
const (
orgID = "org1"
walletID = "wallet1"
)
ids := ceremonyParties()
raw := runCeremony(t, ids, []byte("signing-session"), func(id party.ID) protocol.StartFunc {
return ed25519KeygenStart(id, ids, 1)
})
cfg, ok := raw[ids[0]].(*frost.Config)
require.True(t, ok, "keygen must produce *frost.Config, got %T", raw[ids[0]])
store := mapKVStore{}
shareBytes, err := MarshalEd25519Config(cfg)
require.NoError(t, err)
require.NoError(t, store.Put(OrgScopedKey(orgID, Ed25519ShareKey(walletID)), shareBytes))
session, err := newFROSTSigningSession(
"session1", walletID, message,
nil, // pubSub
ids[0], ids[:2],
store,
nil, // keyinfoStore
nil, // resultQueue
nil, // identityStore
false, // useBroadcast
orgID,
)
require.NoError(t, err, "the session must load the share keygen wrote")
return session, cfg
}
// TestSigningSessionDoesNotHashTheMessage is the regression guard for the
// pre-hash bug.
//
// The session used to SHA-256 any message that was not already 32 bytes, because
// BIP-340 requires a 32-byte digest. PureEdDSA does not: it hashes the message
// inside the challenge, so hashing here signs a digest of a digest and every
// chain rejects the result. The session must carry the caller's bytes through
// untouched, whatever their length.
func TestSigningSessionDoesNotHashTheMessage(t *testing.T) {
for name, message := range map[string][]byte{
"solana_sized_512_bytes": bytes.Repeat([]byte{0xAB}, 512),
"short_11_bytes": []byte("hello world"),
"ton_cell_hash_32_bytes": func() []byte { h := sha256.Sum256([]byte("cell")); return h[:] }(),
"one_byte": {0x01},
"empty": {},
} {
t.Run(name, func(t *testing.T) {
session, _ := newTestEd25519Session(t, message)
require.Equal(t, message, session.message,
"the session must sign the caller's bytes, not a digest of them")
// Explicitly rule out the exact transformation that used to happen,
// so this fails loudly rather than subtly if it comes back.
if len(message) != 32 {
digest := sha256.Sum256(message)
require.NotEqual(t, digest[:], session.message,
"the session re-introduced the SHA-256 pre-hash")
}
})
}
}
// TestSigningSessionLoadsTheEd25519Share pins the seam between the two halves:
// the session must read the share at the key keygen wrote, and must reconstruct
// the same wallet key. A mismatch here is an address that can receive and never
// spend.
func TestSigningSessionLoadsTheEd25519Share(t *testing.T) {
message := []byte("a message of no particular length")
session, original := newTestEd25519Session(t, message)
require.NotNil(t, session.config, "the session must have loaded a config")
loaded, err := session.config.PublicKey.MarshalBinary()
require.NoError(t, err)
expected, err := original.PublicKey.MarshalBinary()
require.NoError(t, err)
require.Equal(t, expected, loaded,
"the signing session must reconstruct the wallet key keygen published")
require.Equal(t, original.Threshold, session.config.Threshold)
}
// TestSigningSessionRefusesAMissingShare confirms the session fails closed when
// no Ed25519 share exists — the normal case for a wallet whose Ed25519 keygen
// leg failed. It must error rather than construct a session that would later
// sign with something else.
func TestSigningSessionRefusesAMissingShare(t *testing.T) {
ids := ceremonyParties()
_, err := newFROSTSigningSession(
"session1", "no-such-wallet", []byte("msg"),
nil, ids[0], ids[:2],
mapKVStore{},
nil, nil, nil, false, "org1",
)
require.Error(t, err, "a wallet with no Ed25519 share must not yield a signing session")
}
// TestSigningSessionRefusesAForeignShare confirms the curve boundary holds at
// load time: a share written by another curve's ceremony must not load, so it
// can never reach frost.SignEd25519.
func TestSigningSessionRefusesAForeignShare(t *testing.T) {
const (
orgID = "org1"
walletID = "wallet1"
)
ids := ceremonyParties()
raw := runCeremony(t, ids, []byte("foreign-signing"), func(id party.ID) protocol.StartFunc {
return frost.KeygenSR25519(id, ids, 1)
})
sr, ok := raw[ids[0]].(*frost.Config)
require.True(t, ok)
blob, err := MarshalSR25519Config(sr)
require.NoError(t, err)
store := mapKVStore{}
require.NoError(t, store.Put(OrgScopedKey(orgID, Ed25519ShareKey(walletID)), blob))
_, err = newFROSTSigningSession(
"session1", walletID, []byte("msg"),
nil, ids[0], ids[:2],
store,
nil, nil, nil, false, orgID,
)
require.Error(t, err, "a Ristretto255 share must not load into the Ed25519 signer")
}
+374
View File
@@ -0,0 +1,374 @@
package mpc
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"testing"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/pkg/taproot"
"github.com/luxfi/threshold/protocols/frost"
"github.com/mr-tron/base58"
"github.com/stretchr/testify/require"
"github.com/luxfi/mpc/pkg/address"
)
// ed25519Keygen runs the keygen the daemon's Ed25519 session runs, then puts
// every share through the CBOR codec the daemon actually stores it with.
//
// The storage round trip is part of the ceremony on purpose. Signing does not
// use the config keygen produced in memory — it uses whatever came back out of
// the kvstore. A codec that dropped or mangled a field would produce a wallet
// that looked correct at keygen and could not sign afterwards, which is exactly
// the class of failure these tests exist to rule out.
func ed25519Keygen(t *testing.T, ids []party.ID, threshold int, sessionID []byte) map[party.ID]*frost.Config {
t.Helper()
raw := runCeremony(t, ids, sessionID, func(id party.ID) protocol.StartFunc {
// Mirrors frostKeygenSession.Init().
return ed25519KeygenStart(id, ids, threshold)
})
configs := make(map[party.ID]*frost.Config, len(raw))
for id, res := range raw {
cfg, ok := res.(*frost.Config)
require.True(t, ok, "party %s: want *frost.Config, got %T", id, res)
// Mirrors frostKeygenSession.publishResult() writing the share, and
// newFROSTSigningSession reading it back.
blob, err := MarshalEd25519Config(cfg)
require.NoError(t, err, "party %s: share did not marshal", id)
restored, err := UnmarshalEd25519Config(blob)
require.NoError(t, err, "party %s: share did not survive storage", id)
configs[id] = restored
}
return configs
}
// publishedPublicKey extracts the key exactly as frostKeygenSession.publishResult
// does, and asserts every party agrees on it. Disagreement would mean the ring
// had minted several different wallets while believing it had minted one.
func publishedPublicKey(t *testing.T, configs map[party.ID]*frost.Config, ids []party.ID) []byte {
t.Helper()
pub, err := configs[ids[0]].PublicKey.MarshalBinary()
require.NoError(t, err)
require.Len(t, pub, 32)
for _, id := range ids {
other, err := configs[id].PublicKey.MarshalBinary()
require.NoError(t, err)
require.Equal(t, pub, other, "party %s disagrees on the wallet public key", id)
}
return pub
}
// TestEd25519WalletAddressIsSpendable is the test this change exists to pass.
//
// It runs the daemon's real keygen, stores and reloads the shares with the
// daemon's real codec, derives the address with the daemon's real derivation,
// then decodes that address back to bytes and verifies a threshold signature
// against THOSE bytes with the standard library. Verifying against the config's
// public key would prove only that the ceremony is self-consistent; verifying
// against the address proves that the specific string a customer would be handed
// is one the ring can sign for. That is the only property that means anything to
// someone who has just deposited into it.
func TestEd25519WalletAddressIsSpendable(t *testing.T) {
ids := ceremonyParties() // node-a, node-b, node-c
const threshold = 1 // degree 1 => any 2 of the 3 can sign
configs := ed25519Keygen(t, ids, threshold, []byte("spendable-keygen"))
pub := publishedPublicKey(t, configs, ids)
// The address mpcd would hand a customer.
solAddress, err := address.Solana(pub)
require.NoError(t, err, "keygen must produce a key that yields a Solana address")
require.NotEmpty(t, solAddress)
// Go back the way a customer's funds do: from the address string to the key
// the chain will check signatures against. Everything below verifies against
// this, never against the config.
addressKey, err := base58.Decode(solAddress)
require.NoError(t, err, "the published address must base58-decode")
require.Len(t, addressKey, ed25519.PublicKeySize)
require.Equal(t, pub, addressKey, "the address must encode the wallet key and nothing else")
// A message that is deliberately not 32 bytes. PureEdDSA signs the message
// itself, so any pre-hashing on the signing path would sign a digest instead
// and this would fail — which is precisely how the old BIP-340 path, which
// SHA-256'd anything that was not already 32 bytes, would be caught.
message := []byte("transfer 1 SOL to a customer; this message is longer than 32 bytes")
require.NotEqual(t, 32, len(message))
signers := ids[:threshold+1] // a strict subset: Lagrange interpolation is real
rawSign := runCeremony(t, signers, []byte("spendable-sign"), func(id party.ID) protocol.StartFunc {
// Mirrors frostSigningSession.Init().
return ed25519SignStart(configs[id], signers, message)
})
// SignEd25519 resolves to a value, not a pointer.
sig, ok := rawSign[signers[0]].(frost.Ed25519Signature)
require.True(t, ok, "want frost.Ed25519Signature, got %T", rawSign[signers[0]])
encoded, err := sig.MarshalBinary()
require.NoError(t, err)
require.Len(t, encoded, ed25519.SignatureSize)
// THE assertion: the standard library, the address, the message.
require.True(t, ed25519.Verify(ed25519.PublicKey(addressKey), message, encoded),
"a signature from the ring must verify against the address mpcd publishes")
// Emit the evidence rather than leaving it implicit in a green tick.
t.Logf("sol_address = %s", solAddress)
t.Logf("pubkey (from addr)= %x", addressKey)
t.Logf("signature R||S = %x", encoded)
t.Logf("ed25519.Verify = PASS (%d-of-%d ring, signers %v)", threshold+1, len(ids), signers)
// Every signer subset must work, or the wallet is spendable only by luck.
for i := range ids {
for j := i + 1; j < len(ids); j++ {
subset := []party.ID{ids[i], ids[j]}
t.Run("subset_"+string(subset[0])+"_"+string(subset[1]), func(t *testing.T) {
raw := runCeremony(t, subset, []byte("spendable-subset"), func(id party.ID) protocol.StartFunc {
return ed25519SignStart(configs[id], subset, message)
})
s, ok := raw[subset[0]].(frost.Ed25519Signature)
require.True(t, ok)
enc, err := s.MarshalBinary()
require.NoError(t, err)
require.True(t, ed25519.Verify(ed25519.PublicKey(addressKey), message, enc),
"subset %v cannot sign for the published address", subset)
})
}
}
}
// TestEd25519SignsRealisticPayloads covers the two payload shapes the wallet
// actually sends: a Solana transaction message, which is hundreds of bytes, and
// a TON cell hash, which is exactly 32. The 32-byte case is the dangerous one —
// it is the length the old signing path passed through untouched while hashing
// everything else, so a pre-hash bug would hide behind it.
func TestEd25519SignsRealisticPayloads(t *testing.T) {
ids := ceremonyParties()
const threshold = 1
configs := ed25519Keygen(t, ids, threshold, []byte("payloads-keygen"))
pub := publishedPublicKey(t, configs, ids)
signers := ids[:threshold+1]
tonCellHash := sha256.Sum256([]byte("ton transfer body cell"))
solanaTxMessage := make([]byte, 512)
for i := range solanaTxMessage {
solanaTxMessage[i] = byte(i)
}
for name, message := range map[string][]byte{
"solana_transaction_message": solanaTxMessage,
"ton_cell_hash_32_bytes": tonCellHash[:],
"empty_message": {},
} {
t.Run(name, func(t *testing.T) {
raw := runCeremony(t, signers, []byte("payloads-sign-"+name), func(id party.ID) protocol.StartFunc {
return ed25519SignStart(configs[id], signers, message)
})
sig, ok := raw[signers[0]].(frost.Ed25519Signature)
require.True(t, ok)
encoded, err := sig.MarshalBinary()
require.NoError(t, err)
require.True(t, ed25519.Verify(ed25519.PublicKey(pub), message, encoded),
"ring must sign %s as-is, without hashing it first", name)
})
}
}
// TestTaprootKeygenCannotReachTheEd25519Path is the regression guard for the bug
// this change removes, and it asserts the barrier that actually holds.
//
// The barrier is provenance, expressed as a type. FROST over secp256k1 finishes
// as *frost.TaprootConfig; the Ed25519 keygen session accepts *frost.Config and
// nothing else, and refuses any other concrete type rather than reaching into
// it. That check is deterministic, which the curve check on the resulting bytes
// is not — see TestSolanaGateIsABackstopNotABarrier.
func TestTaprootKeygenCannotReachTheEd25519Path(t *testing.T) {
ids := ceremonyParties()
raw := runCeremony(t, ids, []byte("taproot-regression"), func(id party.ID) protocol.StartFunc {
return frost.KeygenTaproot(id, ids, 1)
})
cfg, ok := raw[ids[0]].(*frost.TaprootConfig)
require.True(t, ok, "want *frost.TaprootConfig, got %T", raw[ids[0]])
// The premise: 32 bytes, so no length test can tell it apart from Ed25519.
taprootPub := []byte(cfg.PublicKey)
require.Len(t, taprootPub, 32)
_, err := curve.Secp256k1{}.LiftX(taprootPub)
require.NoError(t, err, "this key really is a secp256k1 x-only key")
// The barrier: a Taproot result is not a *frost.Config, so the type
// assertion in frostKeygenSession.handleProtocolMessages rejects it.
_, isEd25519Config := raw[ids[0]].(*frost.Config)
require.False(t, isEd25519Config,
"a Taproot ceremony must not satisfy the Ed25519 session's result type")
// And the library refuses to sign an Ed25519 signature with a secp256k1
// config even if one were somehow assembled, so the wrong curve cannot reach
// a signer by any route.
lifted, err := curve.Secp256k1{}.LiftX(taprootPub)
require.NoError(t, err)
shares := make(map[party.ID]curve.Point, len(cfg.VerificationShares))
for id, p := range cfg.VerificationShares {
shares[id] = p
}
secpConfig := &frost.Config{
ID: cfg.ID,
Threshold: cfg.Threshold,
PrivateShare: cfg.PrivateShare,
PublicKey: lifted,
VerificationShares: party.NewPointMap(shares),
}
_, err = frost.SignEd25519(secpConfig, ids[:2], []byte("withdraw"))(nil)
require.Error(t, err, "SignEd25519 must refuse a secp256k1 config")
}
// TestSolanaGateIsABackstopNotABarrier measures the property the address check
// really has, rather than the one it is tempting to assume.
//
// A BIP-340 x-only key is a 32-byte string. Roughly half of those decode to some
// edwards25519 point, and one in eight of those lands in the prime-order
// subgroup, so about one secp256k1 key in sixteen is ALSO a perfectly valid
// Ed25519 public key and the curve check accepts it. This is not a defect in the
// check — no function of the bytes alone can do better, because such a key is
// genuinely both. It is the reason provenance is the primary defence and this
// gate is only a backstop.
//
// The test asserts the rate is high enough to be worth having and explicitly
// tolerates the coincidences, so it is not flaky and does not encourage anyone
// to rely on it as a guarantee.
func TestSolanaGateIsABackstopNotABarrier(t *testing.T) {
const samples = 2048
secpKeys, refused := 0, 0
buf := make([]byte, 32)
for i := 0; i < samples*4 && secpKeys < samples; i++ {
_, err := rand.Read(buf)
require.NoError(t, err)
if _, err := (curve.Secp256k1{}).LiftX(buf); err != nil {
continue // not a secp256k1 x-only key; not the case under study
}
secpKeys++
addr, err := address.Solana(buf)
if err != nil {
require.ErrorIs(t, err, address.ErrNotEd25519)
require.Empty(t, addr, "an error must never be accompanied by an address")
refused++
continue
}
// Accepted. That is only sound because these bytes really are a valid
// Ed25519 key too — assert that, so an accept can never mean the check
// simply did not run.
require.NoError(t, (curve.Ed25519{}).NewPoint().UnmarshalBinary(buf),
"the gate accepted %x without it being a valid Ed25519 point", buf)
}
require.Equal(t, samples, secpKeys, "did not gather enough secp256k1 keys")
rate := float64(refused) / float64(secpKeys)
t.Logf("address gate refused %d/%d secp256k1 x-only keys (%.1f%%); the rest are genuinely valid Ed25519 keys too",
refused, secpKeys, rate*100)
// Expected ~15/16 = 93.75%. A wide band keeps this a property test, not a
// coin-flip. If this ever drops near zero the gate has stopped working.
require.Greater(t, rate, 0.85, "the curve gate should refuse the large majority of secp256k1 keys")
require.Less(t, rate, 1.0, "if it refused all of them the sample is not what this test claims to measure")
}
// TestTaprootSignatureNeverSatisfiesEd25519 keeps the fund-loss proof itself
// alive: the ring genuinely signs, and the standard library genuinely rejects
// it. If this ever passes, the two schemes have been confused again.
func TestTaprootSignatureNeverSatisfiesEd25519(t *testing.T) {
ids := ceremonyParties()
rawKeygen := runCeremony(t, ids, []byte("taproot-sig-keygen"), func(id party.ID) protocol.StartFunc {
return frost.KeygenTaproot(id, ids, 1)
})
configs := make(map[party.ID]*frost.TaprootConfig, len(rawKeygen))
for id, res := range rawKeygen {
cfg, ok := res.(*frost.TaprootConfig)
require.True(t, ok, "party %s: want *frost.TaprootConfig, got %T", id, res)
configs[id] = cfg
}
signers := ids[:2]
digest := sha256.Sum256([]byte("withdraw 1 SOL"))
rawSign := runCeremony(t, signers, []byte("taproot-sig-sign"), func(id party.ID) protocol.StartFunc {
return frost.SignTaproot(configs[id], signers, digest[:])
})
sig, ok := rawSign[signers[0]].(taproot.Signature)
require.True(t, ok)
pub := []byte(configs[ids[0]].PublicKey)
// It is a valid signature — under BIP-340.
require.True(t, taproot.PublicKey(pub).Verify(sig, digest[:]),
"the ceremony must really produce a valid BIP-340 signature")
// And worthless under Ed25519, which is what Solana checks.
require.False(t, ed25519.Verify(ed25519.PublicKey(pub), digest[:], sig),
"a BIP-340 signature must never satisfy Ed25519 verification")
}
// TestGenericFrostSignatureIsNotSpendable closes the remaining trap in the
// library's API. frost.Sign over an Ed25519 config returns 64 bytes in the same
// shape as an Ed25519 signature, but its challenge is BLAKE3 and its S is
// big-endian, so no chain will accept it. Only SignEd25519 may be used, and this
// test fails if anyone reaches for the generic entry point.
func TestGenericFrostSignatureIsNotSpendable(t *testing.T) {
ids := ceremonyParties()
const threshold = 1
configs := ed25519Keygen(t, ids, threshold, []byte("generic-keygen"))
pub := publishedPublicKey(t, configs, ids)
signers := ids[:threshold+1]
message := []byte("withdraw 1 SOL")
rawSign := runCeremony(t, signers, []byte("generic-sign"), func(id party.ID) protocol.StartFunc {
return frost.Sign(configs[id], signers, message)
})
sig, ok := rawSign[signers[0]].(frost.Signature)
require.True(t, ok, "want frost.Signature, got %T", rawSign[signers[0]])
encoded, err := sig.MarshalBinary()
require.NoError(t, err)
require.Len(t, encoded, 64, "the trap is that it is exactly signature-shaped")
require.False(t, ed25519.Verify(ed25519.PublicKey(pub), message, encoded),
"generic FROST output must never be mistaken for an RFC 8032 signature")
}
// TestEd25519StorageRoundTripRejectsForeignShares checks the codec refuses a
// share from another curve rather than silently reinterpreting its bytes. This
// is what makes the "ed25519:" storage prefix a boundary and not just a label.
func TestEd25519StorageRoundTripRejectsForeignShares(t *testing.T) {
ids := ceremonyParties()
raw := runCeremony(t, ids, []byte("foreign-share"), func(id party.ID) protocol.StartFunc {
return frost.KeygenSR25519(id, ids, 1)
})
sr, ok := raw[ids[0]].(*frost.Config)
require.True(t, ok)
// Written with the sr25519 codec, then offered to the Ed25519 one.
blob, err := MarshalSR25519Config(sr)
require.NoError(t, err)
_, err = UnmarshalEd25519Config(blob)
require.Error(t, err, "a Ristretto255 share must not load as an Ed25519 share")
}
-92
View File
@@ -1,92 +0,0 @@
package mpc
import (
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/protocols/frost"
)
// taprootConfigMarshal is the CBOR-friendly representation of TaprootConfig
type taprootConfigMarshal struct {
ID party.ID
Threshold int
PrivateShare []byte // 32 bytes (Secp256k1Scalar)
PublicKey []byte // 32 bytes (x-only Taproot public key)
ChainKey []byte // 32 bytes
VerificationShares []verificationShareMarshal
}
type verificationShareMarshal struct {
ID party.ID
Point []byte // 33 bytes (compressed Secp256k1Point)
}
// MarshalFROSTConfig serializes a TaprootConfig to CBOR bytes
func MarshalFROSTConfig(config *frost.TaprootConfig) ([]byte, error) {
// Marshal private share
privateShareBytes, err := config.PrivateShare.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal private share: %w", err)
}
// Marshal verification shares
shares := make([]verificationShareMarshal, 0, len(config.VerificationShares))
for id, point := range config.VerificationShares {
pointBytes, err := point.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal verification share for %s: %w", id, err)
}
shares = append(shares, verificationShareMarshal{
ID: id,
Point: pointBytes,
})
}
cm := &taprootConfigMarshal{
ID: config.ID,
Threshold: config.Threshold,
PrivateShare: privateShareBytes,
PublicKey: config.PublicKey,
ChainKey: config.ChainKey,
VerificationShares: shares,
}
return cbor.Marshal(cm)
}
// UnmarshalFROSTConfig deserializes CBOR bytes to a TaprootConfig
func UnmarshalFROSTConfig(data []byte) (*frost.TaprootConfig, error) {
cm := &taprootConfigMarshal{}
if err := cbor.Unmarshal(data, cm); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Unmarshal private share
group := curve.Secp256k1{}
privateShare := group.NewScalar().(*curve.Secp256k1Scalar)
if err := privateShare.UnmarshalBinary(cm.PrivateShare); err != nil {
return nil, fmt.Errorf("failed to unmarshal private share: %w", err)
}
// Unmarshal verification shares
verificationShares := make(map[party.ID]*curve.Secp256k1Point, len(cm.VerificationShares))
for _, vs := range cm.VerificationShares {
point := group.NewPoint().(*curve.Secp256k1Point)
if err := point.UnmarshalBinary(vs.Point); err != nil {
return nil, fmt.Errorf("failed to unmarshal verification share for %s: %w", vs.ID, err)
}
verificationShares[vs.ID] = point
}
return &frost.TaprootConfig{
ID: cm.ID,
Threshold: cm.Threshold,
PrivateShare: privateShare,
PublicKey: cm.PublicKey,
ChainKey: cm.ChainKey,
VerificationShares: verificationShares,
}, nil
}
+90 -53
View File
@@ -13,7 +13,6 @@ import (
"github.com/nats-io/nats.go"
"github.com/rs/zerolog"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/identity"
"github.com/luxfi/mpc/pkg/keyinfo"
"github.com/luxfi/mpc/pkg/kvstore"
@@ -22,15 +21,41 @@ import (
"github.com/luxfi/mpc/pkg/utils"
)
// FROSTKeygenSession interface for FROST keygen
// FROSTKeygenSession runs FROST distributed key generation over edwards25519.
//
// The group is not incidental. The key this ceremony produces is a plain RFC
// 8032 Ed25519 public key: base58-encoded it is a Solana address, and it is the
// key a TON wallet contract stores. It must therefore come from
// frost.KeygenEd25519 and from nothing else. FROST over secp256k1 (the Taproot
// entry point this session used to call) yields a 32-byte key too, which is why
// the mistake is invisible downstream — the length matches, the address parses,
// and the funds never move.
type FROSTKeygenSession interface {
Session
// GetPublicKey returns the 32-byte Ed25519 public key after keygen completes.
GetPublicKey() []byte
}
// ed25519KeygenStart names the keygen primitive for the Ed25519 route, once.
//
// It exists so that the ceremony the daemon runs and the ceremony the tests run
// cannot drift apart. If the tests called frost.KeygenEd25519 directly they would
// be asserting on a copy of this decision rather than on the decision itself, and
// would keep passing while the session quietly ran something else — which is
// exactly how the Taproot bug survived: the tests and the code agreed about the
// name "EdDSA" and disagreed about the curve.
//
// KeygenTaproot (BIP-340 over secp256k1) must never appear here. Its key is also
// 32 bytes, so it passes every downstream check and mints a Solana address the
// ring can never sign for.
func ed25519KeygenStart(selfID party.ID, participants []party.ID, threshold int) protocol.StartFunc {
return frost.KeygenEd25519(selfID, participants, threshold)
}
type frostKeygenSession struct {
session
handler *protocol.Handler
config *frost.TaprootConfig
config *frost.Config
messagesCh chan *protocol.Message
resultMutex sync.Mutex
done bool
@@ -70,12 +95,17 @@ func newFROSTKeygenSession(
logger: zerolog.New(utils.ZerologConsoleWriter()).With().Timestamp().Logger(),
processing: newDedupMap(),
processingLock: sync.Mutex{},
// The topic names the curve, not just the scheme. FROST is defined
// over any prime-order group, so "frost" alone does not say what a
// peer is about to agree with us on; a peer running a different
// group under the same topic would be a silent mixed-curve
// ceremony. Naming the group keeps that unreachable.
topicComposer: &TopicComposer{
ComposeBroadcastTopic: func() string {
return fmt.Sprintf("keygen:broadcast:frost:%s", walletID)
return fmt.Sprintf("keygen:broadcast:ed25519:%s", walletID)
},
ComposeDirectTopic: func(nodeID string) string {
return fmt.Sprintf("keygen:direct:frost:%s:%s", nodeID, walletID)
return fmt.Sprintf("keygen:direct:ed25519:%s:%s", nodeID, walletID)
},
},
identityStore: identityStore,
@@ -142,10 +172,9 @@ func (s *frostKeygenSession) Init() {
s.protocolLogger = log.NewTestLogger(log.InfoLevel)
s.logger.Info().Msg("[FROST] Protocol logger created")
// Create FROST keygen protocol for Taproot (Ed25519-based Schnorr)
s.logger.Info().Msg("[FROST] Creating KeygenTaproot start function")
startFunc := frost.KeygenTaproot(s.selfPartyID, s.partyIDs, s.threshold)
s.logger.Info().Msg("[FROST] KeygenTaproot start function created")
s.logger.Info().Msg("[FROST] Creating KeygenEd25519 start function")
startFunc := ed25519KeygenStart(s.selfPartyID, s.partyIDs, s.threshold)
s.logger.Info().Msg("[FROST] KeygenEd25519 start function created")
// Create handler with timeout context for DKG operations
ctx, cancel := context.WithTimeout(context.Background(), KeygenTimeout)
@@ -209,14 +238,25 @@ func (s *frostKeygenSession) handleProtocolMessages() {
s.resultErr = err
s.errCh <- err
} else {
s.config = result.(*frost.TaprootConfig)
if s.config != nil {
s.logger.Info().
Int("publicKeyLen", len(s.config.PublicKey)).
Str("publicKeyHex", fmt.Sprintf("%x", s.config.PublicKey)).
Msg("[FROST-PROTOCOL] handler.Result() returned valid config")
cfg, ok := result.(*frost.Config)
if !ok {
// A different concrete type means a different curve ran.
// Refuse it rather than reach into it: the whole point of
// this session is that only an Ed25519 config may leave it.
err := fmt.Errorf("FROST keygen returned %T, want *frost.Config over edwards25519", result)
s.logger.Error().Err(err).Msg("[FROST-PROTOCOL] wrong config type from keygen")
s.resultErr = err
s.errCh <- err
} else {
s.logger.Warn().Msg("[FROST-PROTOCOL] handler.Result() returned nil config!")
s.config = cfg
if pubBytes, mErr := cfg.PublicKey.MarshalBinary(); mErr == nil {
s.logger.Info().
Int("publicKeyLen", len(pubBytes)).
Str("publicKeyHex", fmt.Sprintf("%x", pubBytes)).
Msg("[FROST-PROTOCOL] handler.Result() returned valid config")
} else {
s.logger.Warn().Err(mErr).Msg("[FROST-PROTOCOL] could not marshal public key")
}
}
}
s.resultMutex.Unlock()
@@ -363,18 +403,13 @@ func (s *frostKeygenSession) publishResult() {
defer s.resultMutex.Unlock()
if s.resultErr != nil {
s.logger.Error().Err(s.resultErr).Msg("FROST: keygen failed with error")
failureEvent := event.CreateKeygenFailure(
s.walletID,
map[string]any{
"error": s.resultErr.Error(),
"protocol": "FROST",
},
)
evtData, _ := json.Marshal(failureEvent)
if err := s.resultQueue.Enqueue(fmt.Sprintf("mpc.mpc_keygen_result.%s", s.walletID), evtData, nil); err != nil {
s.logger.Error().Err(err).Msg("FROST: failed to publish keygen failure event")
}
// Report the failure to the caller and to nobody else. This ceremony is
// one leg of a wallet's key set, not the wallet's verdict: a wallet whose
// secp256k1 leg succeeded is still a good wallet with no Solana address.
// Publishing a keygen failure from here would race the orchestrator's
// success event on the same subject, and whichever landed first would
// win — so the leg reports, and only the orchestrator publishes.
s.logger.Error().Err(s.resultErr).Msg("FROST: Ed25519 keygen failed with error")
// IMPORTANT: Always send to externalFinishChan so WaitForFinish() doesn't block forever
s.externalFinishChan <- ""
return
@@ -387,8 +422,9 @@ func (s *frostKeygenSession) publishResult() {
return
}
// Save key share with frost prefix using CBOR (JSON doesn't preserve crypto types)
shareBytes, err := MarshalFROSTConfig(s.config)
// CBOR, not JSON: the curve types have no JSON marshalers and would
// round-trip to nothing.
shareBytes, err := MarshalEd25519Config(s.config)
if err != nil {
s.logger.Error().Err(err).Msg("FROST: Failed to marshal key share")
// IMPORTANT: Always send to externalFinishChan so WaitForFinish() doesn't block forever
@@ -396,33 +432,29 @@ func (s *frostKeygenSession) publishResult() {
return
}
// Save with frost: prefix to distinguish from ECDSA keys
frostKey := fmt.Sprintf("frost:%s", s.walletID)
if err := s.kvstore.Put(OrgScopedKey(s.orgID, frostKey), shareBytes); err != nil {
if err := s.kvstore.Put(OrgScopedKey(s.orgID, Ed25519ShareKey(s.walletID)), shareBytes); err != nil {
s.logger.Error().Err(err).Msgf("FROST: Failed to save key share for wallet %s", s.walletID)
// IMPORTANT: Always send to externalFinishChan so WaitForFinish() doesn't block forever
s.externalFinishChan <- ""
return
}
// Get public key hex
// Publish the public key only if it re-encodes cleanly. An empty string here
// means no eddsa_pub_key and therefore no Solana address — the system
// declining to name an address it is not certain it can sign for, which is
// the correct outcome, not a degraded one.
var pubKeyHex string
if s.config != nil && len(s.config.PublicKey) > 0 {
pubKeyHex = fmt.Sprintf("%x", s.config.PublicKey)
s.logger.Info().
Int("configPubKeyLen", len(s.config.PublicKey)).
Str("pubKeyHex", pubKeyHex).
Msg("[FROST-PUBLISH] PublicKey available")
pubBytes, err := s.config.PublicKey.MarshalBinary()
if err != nil || len(pubBytes) != 32 {
s.logger.Error().Err(err).
Int("pubKeyLen", len(pubBytes)).
Msg("[FROST-PUBLISH] Ed25519 public key did not marshal to 32 bytes; publishing nothing")
} else {
s.logger.Warn().
Bool("configNil", s.config == nil).
Int("configPubKeyLen", func() int {
if s.config != nil {
return len(s.config.PublicKey)
}
return -1
}()).
Msg("[FROST-PUBLISH] PublicKey is empty or config is nil!")
pubKeyHex = fmt.Sprintf("%x", pubBytes)
s.logger.Info().
Int("configPubKeyLen", len(pubBytes)).
Str("pubKeyHex", pubKeyHex).
Msg("[FROST-PUBLISH] Ed25519 PublicKey available")
}
// Notify via external finish channel
@@ -445,12 +477,17 @@ func (s *frostKeygenSession) WaitForFinish() string {
return <-s.externalFinishChan
}
// GetPublicKey returns the EdDSA public key after keygen completes
// GetPublicKey returns the 32-byte Ed25519 public key after keygen completes,
// or nil if there is not one. nil is a usable answer; a wrong 32 bytes is not.
func (s *frostKeygenSession) GetPublicKey() []byte {
s.resultMutex.Lock()
defer s.resultMutex.Unlock()
if s.config != nil {
return s.config.PublicKey
if s.config == nil || s.config.PublicKey == nil {
return nil
}
return nil
pubBytes, err := s.config.PublicKey.MarshalBinary()
if err != nil {
return nil
}
return pubBytes
}
+7 -12
View File
@@ -17,7 +17,6 @@ import (
"github.com/luxfi/threshold/protocols/cmp/config"
"github.com/rs/zerolog"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/identity"
"github.com/luxfi/mpc/pkg/keyinfo"
"github.com/luxfi/mpc/pkg/kvstore"
@@ -355,18 +354,14 @@ func (s *cggmp21KeygenSession) publishResult() {
defer s.resultMutex.Unlock()
if s.resultErr != nil {
// Report to the caller and to nobody else. This session used to publish
// its own failure onto mpc.mpc_keygen_result.<walletID> with no
// idempotency key, while the orchestrator published the wallet's verdict
// onto the same subject — and the reader is first-wins, so across a ring
// one node's local failure could beat the other nodes' success and
// decide the verdict for a wallet that genuinely exists. One publisher,
// one verdict: the orchestrator's.
s.logger.Error().Err(s.resultErr).Msg("CGGMP21: keygen failed with error")
failureEvent := event.CreateKeygenFailure(
s.walletID,
map[string]any{
"error": s.resultErr.Error(),
"protocol": "CGGMP21",
},
)
evtData, _ := json.Marshal(failureEvent)
if err := s.resultQueue.Enqueue(fmt.Sprintf("mpc.mpc_keygen_result.%s", s.walletID), evtData, nil); err != nil {
s.logger.Error().Err(err).Msg("failed to publish keygen failure event")
}
// IMPORTANT: Always send to externalFinishChan so WaitForFinish() doesn't block forever
s.externalFinishChan <- ""
return
+1 -1
View File
@@ -189,7 +189,7 @@ func isKeyShare(key string) bool {
return true
}
// Known key-type prefixed shares
for _, prefix := range []string{"frost:", "sr25519:", "tfhe:"} {
for _, prefix := range []string{"ed25519:", "sr25519:", "tfhe:"} {
if strings.HasPrefix(key, prefix) {
return true
}
+8 -4
View File
@@ -106,7 +106,8 @@ func (p *Node) CreateKeyGenSession(
return session, nil
}
// CreateEdDSAKeyGenSession creates a FROST keygen session for EdDSA
// CreateEdDSAKeyGenSession creates a FROST keygen session over edwards25519,
// producing an RFC 8032 Ed25519 key (Solana address, TON wallet key)
func (p *Node) CreateEdDSAKeyGenSession(
walletID string,
threshold int,
@@ -321,11 +322,14 @@ func (p *Node) CreateSignSession(
return session, nil
}
// CreateEdDSASignSession creates a FROST signing session for EdDSA (Ed25519)
// CreateEdDSASignSession creates a FROST signing session over edwards25519.
//
// message is the message itself, not a digest. PureEdDSA hashes it inside the
// challenge, so a caller that pre-hashes signs the wrong thing.
func (p *Node) CreateEdDSASignSession(
sessionID string,
walletID string,
messageHash []byte,
message []byte,
signerPeerIDs []string,
resultQueue messaging.MessageQueue,
useBroadcast bool,
@@ -353,7 +357,7 @@ func (p *Node) CreateEdDSASignSession(
session, err := newFROSTSigningSession(
sessionID,
walletID,
messageHash,
message,
p.pubSub,
selfPartyID,
signerPartyIDs,
+62 -44
View File
@@ -2,7 +2,6 @@ package mpc
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
@@ -13,7 +12,6 @@ import (
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/pkg/taproot"
"github.com/luxfi/threshold/protocols/frost"
"github.com/rs/zerolog"
@@ -27,24 +25,42 @@ import (
"github.com/luxfi/mpc/pkg/utils"
)
// curve import used for extracting signature components
// ed25519SignStart names the signing primitive for the Ed25519 route, once, so
// the daemon and the tests cannot drift onto different ones.
//
// It must be SignEd25519 and never the generic Sign. Over this same config, Sign
// emits 64 bytes in exactly the same shape, but its challenge is BLAKE3 and its S
// is big-endian, so it looks like an Ed25519 signature and no verifier on earth
// accepts it.
//
// message is the message, not a digest: PureEdDSA hashes it inside the challenge.
func ed25519SignStart(config *frost.Config, signers []party.ID, message []byte) protocol.StartFunc {
return frost.SignEd25519(config, signers, message)
}
// FROSTSignSession is the interface for EdDSA signing sessions
// FROSTSignSession is the interface for Ed25519 signing sessions.
type FROSTSignSession interface {
Session
}
type frostSigningSession struct {
session
handler *protocol.Handler
pool *pool.Pool
config *frost.TaprootConfig
signature taproot.Signature // 64-byte BIP-340 signature (R_x || s)
handler *protocol.Handler
pool *pool.Pool
config *frost.Config
// signature is the RFC 8032 encoding, R ‖ S with S little-endian, exactly
// what crypto/ed25519.Verify accepts.
signature []byte
// message is the message itself, NOT a digest of it. PureEdDSA hashes the
// message inside the challenge, so anything hashed here would be signed as
// a digest of a digest and rejected on chain. Solana signs the serialized
// transaction message; TON signs the 32 bytes of the cell hash — in both
// cases these are the bytes the caller handed us, unchanged.
message []byte
messagesCh chan *protocol.Message
resultMutex sync.Mutex
done bool
resultErr error
messageHash []byte
signerIDs []party.ID
useBroadcast bool
protocolLogger log.Logger
@@ -53,7 +69,7 @@ type frostSigningSession struct {
func newFROSTSigningSession(
sessionID string,
walletID string,
messageHash []byte,
message []byte,
pubSub messaging.PubSub,
selfPartyID party.ID,
signerIDs []party.ID,
@@ -66,13 +82,13 @@ func newFROSTSigningSession(
) (*frostSigningSession, error) {
// 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)
var config *frost.TaprootConfig
shareKey := Ed25519ShareKey(walletID)
var config *frost.Config
var loadErr error
withSecretErasure(func() {
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, frostKey)
shareBytes, err := GetKeyShareWithFallback(kvstore, orgID, shareKey)
if err != nil {
loadErr = fmt.Errorf("failed to get FROST key share: %w", err)
loadErr = fmt.Errorf("failed to get Ed25519 key share: %w", err)
return
}
defer func() {
@@ -80,26 +96,17 @@ func newFROSTSigningSession(
shareBytes[i] = 0
}
}()
// TaprootConfig is stored as CBOR (to properly preserve crypto types)
config, err = UnmarshalFROSTConfig(shareBytes)
// CBOR, and every point is decoded through curve.Ed25519, so a share
// belonging to another curve cannot load at all.
config, err = UnmarshalEd25519Config(shareBytes)
if err != nil {
loadErr = fmt.Errorf("failed to unmarshal FROST key share: %w", err)
loadErr = fmt.Errorf("failed to unmarshal Ed25519 key share: %w", err)
}
})
if loadErr != nil {
return nil, loadErr
}
// BIP-340/Taproot requires 32-byte message hash
// If message is not already 32 bytes, hash it with SHA-256
var hashedMessage []byte
if len(messageHash) == 32 {
hashedMessage = messageHash
} else {
hash := sha256.Sum256(messageHash)
hashedMessage = hash[:]
}
// Create thread pool
threadPool := pool.NewPool(0) // Use max threads
@@ -125,18 +132,19 @@ func newFROSTSigningSession(
processingLock: sync.Mutex{},
topicComposer: &TopicComposer{
ComposeBroadcastTopic: func() string {
return fmt.Sprintf("sign:broadcast:frost:%s", sessionID)
return fmt.Sprintf("sign:broadcast:ed25519:%s", sessionID)
},
ComposeDirectTopic: func(nodeID string) string {
return fmt.Sprintf("sign:direct:frost:%s:%s", nodeID, sessionID)
return fmt.Sprintf("sign:direct:ed25519:%s:%s", nodeID, sessionID)
},
},
identityStore: identityStore,
},
pool: threadPool,
config: config,
messagesCh: make(chan *protocol.Message, 100),
messageHash: hashedMessage, // Use the SHA-256 hashed message
pool: threadPool,
config: config,
messagesCh: make(chan *protocol.Message, 100),
// Passed through untouched — see the field comment on message.
message: message,
signerIDs: signerIDs,
useBroadcast: useBroadcast,
done: false,
@@ -191,7 +199,7 @@ func (s *frostSigningSession) Init() {
s.logger.Info().
Str("sessionID", s.sessionID).
Str("walletID", s.walletID).
Hex("messageHash", s.messageHash).
Int("messageLen", len(s.message)).
Interface("signerIDs", s.signerIDs).
Bool("useBroadcast", s.useBroadcast).
Msg("Initializing FROST signing session")
@@ -199,8 +207,7 @@ func (s *frostSigningSession) Init() {
// Create protocol logger
s.protocolLogger = log.NewTestLogger(log.InfoLevel)
// Create FROST Taproot signing protocol
startFunc := frost.SignTaproot(s.config, s.signerIDs, s.messageHash)
startFunc := ed25519SignStart(s.config, s.signerIDs, s.message)
// Create handler with timeout context for signing operations
ctx, cancel := context.WithTimeout(context.Background(), SigningTimeout)
@@ -259,8 +266,19 @@ func (s *frostSigningSession) handleProtocolMessages() {
s.resultErr = err
s.errCh <- err
} else {
// FROST Taproot signing returns taproot.Signature which is []byte (64 bytes)
s.signature = result.(taproot.Signature)
// SignEd25519 resolves to a VALUE, not a pointer — asserting
// *frost.Ed25519Signature here would panic.
sig, ok := result.(frost.Ed25519Signature)
if !ok {
err := fmt.Errorf("Ed25519 signing returned %T, want frost.Ed25519Signature", result)
s.resultErr = err
s.errCh <- err
} else if encoded, mErr := sig.MarshalBinary(); mErr != nil {
s.resultErr = fmt.Errorf("failed to encode Ed25519 signature: %w", mErr)
s.errCh <- s.resultErr
} else {
s.signature = encoded
}
}
s.resultMutex.Unlock()
s.finishCh <- true
@@ -417,22 +435,22 @@ func (s *frostSigningSession) publishResult() {
return
}
if s.signature == nil || len(s.signature) != 64 {
if len(s.signature) != 64 {
s.logger.Error().Int("sigLen", len(s.signature)).Msg("FROST: Invalid signature after signing completion (expected 64 bytes)")
s.externalFinishChan <- ""
return
}
// taproot.Signature is already in BIP-340 format: R_x (32 bytes) || s (32 bytes) = 64 bytes
fullSignature := []byte(s.signature)
// RFC 8032 encoding: R (32 bytes) ‖ S (32 bytes, little-endian).
fullSignature := s.signature
s.logger.Info().
Str("sessionID", s.sessionID).
Str("walletID", s.walletID).
Hex("sigRX", fullSignature[:32]).
Hex("sigZ", fullSignature[32:]).
Hex("sigR", fullSignature[:32]).
Hex("sigS", fullSignature[32:]).
Int("sigLen", len(fullSignature)).
Msg("FROST/Taproot signing completed successfully")
Msg("FROST/Ed25519 signing completed successfully")
// Create success event with EdDSA signature format
successEvent := event.SigningResultEvent{