Files
mpc/pkg/address/address.go
T
zeekay cb77fab979 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.
2026-08-06 13:23:18 -07:00

89 lines
4.4 KiB
Go

// 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
}