feat: add deterministic validator key derivation from mnemonic

- Add DeriveValidatorsFromMnemonic and DeriveValidatorFromMnemonic
- Derive EC keys using BIP44 path m/44/9000/0/0/{index}
- Derive TLS keys using BIP44 path m/44/9000/1/0/{index}
- Derive BLS keys using BIP44 path m/44/9000/2/0/{index}
- Add deriveP256Key for deterministic TLS cert generation
- Auto-generate missing TLS certs when loading from directory
- Keys persist to disk for stable NodeIDs across restarts
This commit is contained in:
Zach Kelling
2025-12-22 06:26:43 -08:00
parent 84059d6ece
commit 75db4cb7bb
3 changed files with 266 additions and 9 deletions
+4
View File
@@ -5,12 +5,16 @@ go 1.25.5
require (
github.com/luxfi/constants v1.2.3
github.com/luxfi/crypto v1.17.25
github.com/luxfi/go-bip32 v1.0.1
github.com/luxfi/go-bip39 v1.1.2
github.com/luxfi/ids v1.2.4
github.com/luxfi/node v1.22.37
golang.org/x/crypto v0.46.0
)
require (
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
github.com/cloudflare/circl v1.6.2-0.20251204010831-23491bd573cf // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+12
View File
@@ -1,3 +1,7 @@
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
@@ -24,6 +28,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cloudflare/circl v1.6.2-0.20251204010831-23491bd573cf h1:+0huF756d72ZdHxachRP5HYmvRkkOk8CLyKUmJM138c=
github.com/cloudflare/circl v1.6.2-0.20251204010831-23491bd573cf/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw=
github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -69,6 +75,10 @@ github.com/luxfi/constants v1.2.3 h1:ewo3Ch6AJDI3G42J0rlNAdZU1Fv96YEyOmp3E2K/pzE
github.com/luxfi/constants v1.2.3/go.mod h1:r5WL5Z03I9iHo910pHykO+RuDCwe4jy0rBSueG1l1mo=
github.com/luxfi/crypto v1.17.25 h1:emTejQ17bGj/1tu0ETB196Hbn6fJfo8wUNGIOWit6RM=
github.com/luxfi/crypto v1.17.25/go.mod h1:v6eaW5ejuzW2gecChWKQqx4CtVhoM1b+e40ro/6WVPg=
github.com/luxfi/go-bip32 v1.0.1 h1:UaPUSu6ScZ1wobQJJO+vePAN3OcUHYmq5DEc/uOU+SY=
github.com/luxfi/go-bip32 v1.0.1/go.mod h1:pzlclicfplIRhFE780jsfEY67S3IYF5trCyBfzQOb4c=
github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg=
github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo=
github.com/luxfi/ids v1.2.4 h1:e0OaeSI6xWjS9JnxxxfvCCs9HHDUrwDc3M9ctIhjcDI=
github.com/luxfi/ids v1.2.4/go.mod h1:HwvRJSNbuZS+u+0JqeHfPX2S13iFyLCnfGxjBCmt244=
github.com/luxfi/math v1.0.2 h1:bgBGmVC7S/QxLCgAJZJdvjFHMNXygcApcKofIn7bWpU=
@@ -158,3 +168,5 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM=
+250 -9
View File
@@ -10,9 +10,11 @@ package keys
import (
"crypto/ecdsa"
"crypto/elliptic"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
@@ -20,6 +22,8 @@ import (
"github.com/luxfi/crypto/bls"
"github.com/luxfi/crypto/bls/signer/localsigner"
luxcrypto "github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/go-bip32"
"github.com/luxfi/go-bip39"
"github.com/luxfi/ids"
"github.com/luxfi/node/staking"
"github.com/luxfi/node/vms/platformvm/signer"
@@ -211,28 +215,42 @@ func (ks *KeyStore) Load(name string) (*ValidatorKey, error) {
func LoadFromDir(nodeDir string) (*ValidatorKey, error) {
vk := &ValidatorKey{}
// Load TLS cert - try modern path first
// Load TLS cert - try modern path first, then legacy
certPath := filepath.Join(nodeDir, "staking", "staker.crt")
certPEM, err := os.ReadFile(certPath)
if err != nil {
certPath = filepath.Join(nodeDir, "staker.crt")
certPEM, err = os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read staker.crt: %w", err)
}
}
vk.StakerCert = certPEM
// Load TLS key
keyPath := filepath.Join(nodeDir, "staking", "staker.key")
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
keyPEM, kerr := os.ReadFile(keyPath)
if kerr != nil {
keyPath = filepath.Join(nodeDir, "staker.key")
keyPEM, err = os.ReadFile(keyPath)
keyPEM, kerr = os.ReadFile(keyPath)
}
// If TLS cert/key missing, generate them and persist
if err != nil || kerr != nil || len(certPEM) == 0 || len(keyPEM) == 0 {
fmt.Printf(" Generating TLS staking cert for %s\n", filepath.Base(nodeDir))
certPEM, keyPEM, err = staking.NewCertAndKeyBytes()
if err != nil {
return nil, fmt.Errorf("failed to read staker.key: %w", err)
return nil, fmt.Errorf("failed to generate TLS cert: %w", err)
}
// Save to disk for future use
stakingDir := filepath.Join(nodeDir, "staking")
if err := os.MkdirAll(stakingDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create staking dir: %w", err)
}
if err := os.WriteFile(filepath.Join(stakingDir, "staker.key"), keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write staker.key: %w", err)
}
if err := os.WriteFile(filepath.Join(stakingDir, "staker.crt"), certPEM, 0644); err != nil {
return nil, fmt.Errorf("failed to write staker.crt: %w", err)
}
}
vk.StakerCert = certPEM
vk.StakerKey = keyPEM
// Derive NodeID from TLS cert
@@ -370,3 +388,226 @@ func (vk *ValidatorKey) BLSPoPHex() string {
func (vk *ValidatorKey) CChainAddrHex() string {
return "0x" + hex.EncodeToString(vk.CChainAddr[:])
}
// DeriveValidatorsFromMnemonic derives N validator keys from a BIP39 mnemonic.
// Each validator uses BIP44 path m/44'/9000'/0'/0/{index} for the EC key.
// TLS staking certs and BLS keys are generated fresh (not deterministic from mnemonic).
// This is designed for runtime use - no files are written to disk.
func DeriveValidatorsFromMnemonic(mnemonic string, count int) ([]*ValidatorKey, error) {
if count <= 0 || count > 100 {
return nil, fmt.Errorf("invalid validator count: %d (must be 1-100)", count)
}
validators := make([]*ValidatorKey, count)
for i := 0; i < count; i++ {
vk, err := DeriveValidatorFromMnemonic(mnemonic, uint32(i))
if err != nil {
return nil, fmt.Errorf("failed to derive validator %d: %w", i, err)
}
validators[i] = vk
}
return validators, nil
}
// DeriveValidatorFromMnemonic derives a single validator key from mnemonic at given index.
// All keys (EC, TLS, BLS) are now derived deterministically from the mnemonic.
func DeriveValidatorFromMnemonic(mnemonic string, accountIndex uint32) (*ValidatorKey, error) {
vk := &ValidatorKey{}
// 1. Derive EC key from mnemonic using BIP44 path m/44'/9000'/0'/0/{index}
ecKeyBytes, err := deriveMnemonicKey(mnemonic, accountIndex)
if err != nil {
return nil, fmt.Errorf("failed to derive EC key: %w", err)
}
vk.ECPrivateKey = ecKeyBytes
// Derive P-chain and C-chain addresses
luxPrivKey, err := luxcrypto.ToPrivateKey(ecKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create secp256k1 key: %w", err)
}
pubKey := luxPrivKey.PublicKey()
vk.PChainAddr = ids.ShortID(pubKey.Address())
vk.CChainAddr = pubkeyToAddress(pubKey.ToECDSA())
// 2. Derive TLS staking cert deterministically from mnemonic
// Use a separate derivation path: m/44'/9000'/1'/0/{index} for TLS keys
tlsKeyBytes, err := deriveMnemonicKeyForPath(mnemonic, 1, accountIndex) // account=1 for TLS
if err != nil {
return nil, fmt.Errorf("failed to derive TLS key seed: %w", err)
}
// Create P-256 private key from derived seed (TLS uses P-256, not secp256k1)
p256Key, err := deriveP256Key(tlsKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to derive P-256 key: %w", err)
}
certPEM, keyPEM, err := staking.NewCertAndKeyBytesFromKey(p256Key)
if err != nil {
return nil, fmt.Errorf("failed to generate TLS cert: %w", err)
}
vk.StakerCert = certPEM
vk.StakerKey = keyPEM
// Derive NodeID from TLS cert
tlsCert, err := staking.LoadTLSCertFromBytes(keyPEM, certPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse TLS cert: %w", err)
}
stakingCert := &ids.Certificate{
Raw: tlsCert.Leaf.Raw,
PublicKey: tlsCert.Leaf.PublicKey,
}
vk.NodeID = ids.NodeIDFromCert(stakingCert)
// 3. Derive BLS signer key deterministically from mnemonic
// Use a separate derivation path: m/44'/9000'/2'/0/{index} for BLS keys
blsSeed, err := deriveMnemonicKeyForPath(mnemonic, 2, accountIndex) // account=2 for BLS
if err != nil {
return nil, fmt.Errorf("failed to derive BLS key seed: %w", err)
}
// BLS secret keys are 32 bytes - use the derived seed directly
blsKey, err := localsigner.FromBytes(blsSeed)
if err != nil {
return nil, fmt.Errorf("failed to create BLS key from seed: %w", err)
}
vk.BLSSecretKey = blsKey.ToBytes()
pop, err := signer.NewProofOfPossession(blsKey)
if err != nil {
return nil, fmt.Errorf("failed to generate BLS PoP: %w", err)
}
vk.BLSPublicKey = pop.PublicKey[:]
vk.BLSPoP = pop.ProofOfPossession[:]
return vk, nil
}
// deriveP256Key creates an ECDSA P-256 private key from a 32-byte seed.
// This allows deterministic TLS key generation from mnemonic-derived seeds.
func deriveP256Key(seed []byte) (*ecdsa.PrivateKey, error) {
if len(seed) < 32 {
return nil, fmt.Errorf("seed must be at least 32 bytes")
}
// Use the seed as the private key scalar (reduced mod curve order)
curve := elliptic.P256()
k := new(big.Int).SetBytes(seed[:32])
k.Mod(k, curve.Params().N)
// Ensure k is not zero
if k.Sign() == 0 {
k.SetInt64(1)
}
priv := &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: curve,
},
D: k,
}
priv.PublicKey.X, priv.PublicKey.Y = curve.ScalarBaseMult(k.Bytes())
return priv, nil
}
// deriveMnemonicKeyForPath derives a key using BIP44 path m/44'/9000'/{account}'/0/{index}
func deriveMnemonicKeyForPath(mnemonic string, account, index uint32) ([]byte, error) {
if !bip39.IsMnemonicValid(mnemonic) {
return nil, fmt.Errorf("invalid mnemonic phrase")
}
seed := bip39.NewSeed(mnemonic, "")
// Create master key from seed
masterKey, err := bip32.NewMasterKey(seed)
if err != nil {
return nil, fmt.Errorf("failed to create master key: %w", err)
}
// BIP-44 path: m/44'/9000'/{account}'/0/{index}
// m/44' (purpose)
key, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 44)
if err != nil {
return nil, fmt.Errorf("failed to derive purpose: %w", err)
}
// m/44'/9000' (coin type for LUX)
key, err = key.NewChildKey(bip32.FirstHardenedChild + LUXCoinType)
if err != nil {
return nil, fmt.Errorf("failed to derive coin type: %w", err)
}
// m/44'/9000'/{account}' (account - 0=EC, 1=TLS, 2=BLS)
key, err = key.NewChildKey(bip32.FirstHardenedChild + account)
if err != nil {
return nil, fmt.Errorf("failed to derive account: %w", err)
}
// m/44'/9000'/{account}'/0 (change)
key, err = key.NewChildKey(0)
if err != nil {
return nil, fmt.Errorf("failed to derive change: %w", err)
}
// m/44'/9000'/{account}'/0/{index} (address index)
key, err = key.NewChildKey(index)
if err != nil {
return nil, fmt.Errorf("failed to derive address index: %w", err)
}
return key.Key, nil
}
// LUXCoinType is the BIP-44 coin type for LUX (9000')
const LUXCoinType = 9000
// deriveMnemonicKey derives an EC private key from mnemonic using BIP44 path m/44'/9000'/0'/0/{index}
func deriveMnemonicKey(mnemonic string, accountIndex uint32) ([]byte, error) {
if !bip39.IsMnemonicValid(mnemonic) {
return nil, fmt.Errorf("invalid mnemonic phrase")
}
seed := bip39.NewSeed(mnemonic, "")
// Create master key from seed
masterKey, err := bip32.NewMasterKey(seed)
if err != nil {
return nil, fmt.Errorf("failed to create master key: %w", err)
}
// BIP-44 path: m/44'/9000'/0'/0/{accountIndex}
// m/44' (purpose)
key, err := masterKey.NewChildKey(bip32.FirstHardenedChild + 44)
if err != nil {
return nil, fmt.Errorf("failed to derive purpose: %w", err)
}
// m/44'/9000' (coin type for LUX)
key, err = key.NewChildKey(bip32.FirstHardenedChild + LUXCoinType)
if err != nil {
return nil, fmt.Errorf("failed to derive coin type: %w", err)
}
// m/44'/9000'/0' (account)
key, err = key.NewChildKey(bip32.FirstHardenedChild + 0)
if err != nil {
return nil, fmt.Errorf("failed to derive account: %w", err)
}
// m/44'/9000'/0'/0 (change)
key, err = key.NewChildKey(0)
if err != nil {
return nil, fmt.Errorf("failed to derive change: %w", err)
}
// m/44'/9000'/0'/0/{accountIndex} (address index)
key, err = key.NewChildKey(accountIndex)
if err != nil {
return nil, fmt.Errorf("failed to derive address index: %w", err)
}
return key.Key, nil
}