diff --git a/README.md b/README.md index 65da989..7624e6d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,68 @@ make build ### Prerequisites - Ethereum RPC endpoint to send transactions. +## ECDSA Key Management + +The CLI supports two methods for providing ECDSA private keys for transaction signing: + +### Option 1: Encrypted Keystore Files (Recommended) + +For enhanced security, use encrypted keystore files compatible with EigenLayer CLI: + +```bash +# Generate a new ECDSA key using EigenLayer CLI +eigenlayer operator keys create --key-type ecdsa [keyname] + +# Use the keystore file with etherfi-avs-operator-CLI +./avs-cli arpa register-node \ + --operator-id 1 \ + --dkg-public-key "0x..." \ + --registration-signature input.json \ + --ecdsa-keystore /path/to/keystore.json \ + --ecdsa-password "your_password" +``` + +### Option 2: Environment Variables (Legacy) + +For backward compatibility, you can still use environment variables: + +```bash +export PRIVATE_KEY="your_private_key_hex" +export ADMIN_1271_SIGNING_KEY="admin_signing_key_hex" +export WATCHTOWER_PRIVATE_KEY="watchtower_private_key_hex" # For witness-chain only + +./avs-cli arpa register-node \ + --operator-id 1 \ + --dkg-public-key "0x..." \ + --registration-signature input.json +``` + +### Migration Guide + +If you're currently using environment variables, we recommend migrating to encrypted keystore files: + +1. **Create a keystore file from your existing private key:** + ```bash + # Using EigenLayer CLI + eigenlayer operator keys import --key-type ecdsa [keyname] [private_key_hex] + ``` + +2. **Update your commands to use keystore flags:** + - Add `--ecdsa-keystore /path/to/keystore.json` + - Add `--ecdsa-password "your_password"` + - Remove environment variable exports + +3. **Supported commands with keystore support:** + - `arpa register-node` + - `arpa generate-registration-signature` + - `update-ecdsa-signer` + - `witness-chain prepare-registration` + +### Security Benefits + +- **No raw private keys in environment:** Keystore files are encrypted with your password +- **Industry standard:** Compatible with EigenLayer CLI keystore format +- **Better operational security:** Reduces risk of key exposure in process environments ## Step 1: Request ether.fi team to be registered as a Delegated AVS operator diff --git a/bin/avs-cli/arpa/arpa.go b/bin/avs-cli/arpa/arpa.go index 7888fd5..396706d 100644 --- a/bin/avs-cli/arpa/arpa.go +++ b/bin/avs-cli/arpa/arpa.go @@ -8,11 +8,11 @@ import ( "fmt" "os" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/avs/arpa" "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/config" "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/etherfi" + "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/utils" "github.com/urfave/cli/v3" ) @@ -61,7 +61,7 @@ var RegisterCmd = &cli.Command{ Name: "register-node", Usage: "(Node Operator) register a node with the ARPA service", Action: handleRegistration, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.IntFlag{ Name: "operator-id", Usage: "Operator ID", @@ -77,7 +77,7 @@ var RegisterCmd = &cli.Command{ Usage: "path to registration signature file created by prepare-registration command", Required: true, }, - }, + }, utils.GetECDSAKeystoreFlags()...), } func handleRegistration(ctx context.Context, cli *cli.Command) error { @@ -119,9 +119,9 @@ func handleRegistration(ctx context.Context, cli *cli.Command) error { } // load `Node Account` private key - signingKey, err := crypto.HexToECDSA(os.Getenv("PRIVATE_KEY")) + signingKey, err := utils.LoadECDSAKey(cli, "PRIVATE_KEY") if err != nil { - return fmt.Errorf("invalid private key: %w", err) + return fmt.Errorf("loading Node Account private key: %w", err) } return arpaAPI.Register(operator, dkgPublicKey, inputSignature, signingKey) @@ -131,13 +131,13 @@ var GenerationRegisterSignatureCmd = &cli.Command{ Name: "generate-registration-signature", Usage: "(Admin) generate a registration signature for the given operator", Action: handleGenerateRegistrationSignature, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.IntFlag{ Name: "operator-id", Usage: "Operator ID", Required: true, }, - }, + }, utils.GetECDSAKeystoreFlags()...), } func handleGenerateRegistrationSignature(ctx context.Context, cli *cli.Command) error { @@ -152,9 +152,9 @@ func handleGenerateRegistrationSignature(ctx context.Context, cli *cli.Command) } // load eip-1271 admin signing key - signingKey, err := crypto.HexToECDSA(os.Getenv("ADMIN_1271_SIGNING_KEY")) + signingKey, err := utils.LoadECDSAKey(cli, "ADMIN_1271_SIGNING_KEY") if err != nil { - return fmt.Errorf("invalid private key: %w", err) + return fmt.Errorf("loading admin signing key: %w", err) } return arpaAPI.GenerateAVSRegistrationSignature(operator, signingKey) diff --git a/bin/avs-cli/update_ecdsa_signer.go b/bin/avs-cli/update_ecdsa_signer.go index f7b4b96..a7a820c 100644 --- a/bin/avs-cli/update_ecdsa_signer.go +++ b/bin/avs-cli/update_ecdsa_signer.go @@ -6,14 +6,13 @@ import ( "encoding/hex" "fmt" "math/big" - "os" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/config" "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/etherfi" + "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/utils" "github.com/urfave/cli/v3" ) @@ -21,7 +20,7 @@ var updateEcdsaSignerCmd = &cli.Command{ Name: "update-ecdsa-signer", Usage: "(Ether.fi Admin) the signer associated with this operator", Action: handleUpdateEcdsaSigner, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.IntFlag{ Name: "operator-id", Usage: "Operator ID", @@ -40,7 +39,7 @@ var updateEcdsaSignerCmd = &cli.Command{ Name: "rpc-url", Usage: "rpc url", }, - }, + }, utils.GetECDSAKeystoreFlags()...), } func handleUpdateEcdsaSigner(ctx context.Context, cli *cli.Command) error { @@ -56,10 +55,10 @@ func handleUpdateEcdsaSigner(ctx context.Context, cli *cli.Command) error { return fmt.Errorf("dialing rpc: %w", err) } - return updateEcdsaSigner(rpcClient, operatorID, ecdsaSigner, broadcast) + return updateEcdsaSigner(rpcClient, operatorID, ecdsaSigner, broadcast, cli) } -func updateEcdsaSigner(rpcClient *ethclient.Client, operatorID int64, ecdsaSigner common.Address, broadcast bool) error { +func updateEcdsaSigner(rpcClient *ethclient.Client, operatorID int64, ecdsaSigner common.Address, broadcast bool, cli *cli.Command) error { // load configuration chainID, err := rpcClient.ChainID(context.Background()) @@ -78,7 +77,7 @@ func updateEcdsaSigner(rpcClient *ethclient.Client, operatorID int64, ecdsaSigne } // load signing key - privateKey, err := crypto.HexToECDSA(os.Getenv("PRIVATE_KEY")) + privateKey, err := utils.LoadECDSAKey(cli, "PRIVATE_KEY") if err != nil { return fmt.Errorf("loading signing key: %w", err) } diff --git a/bin/avs-cli/witness-chain/prepareRegistration.go b/bin/avs-cli/witness-chain/prepareRegistration.go index 7ee5eb0..09797a6 100644 --- a/bin/avs-cli/witness-chain/prepareRegistration.go +++ b/bin/avs-cli/witness-chain/prepareRegistration.go @@ -4,10 +4,9 @@ import ( "context" "fmt" "math/big" - "os" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" + "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/utils" "github.com/urfave/cli/v3" ) @@ -22,13 +21,13 @@ var WitnessPrepareRegistrationCmd = &cli.Command{ Name: "prepare-registration", Usage: "(Node Operator) gather all inputs required to register for avs", Action: handleWitnessPrepareRegistration, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ &cli.IntFlag{ Name: "operator-id", Usage: "Operator ID", Required: true, }, - }, + }, utils.GetECDSAKeystoreFlags()...), } func handleWitnessPrepareRegistration(ctx context.Context, cli *cli.Command) error { @@ -37,9 +36,9 @@ func handleWitnessPrepareRegistration(ctx context.Context, cli *cli.Command) err operatorID := cli.Int("operator-id") // load watchtower private key - watchtowerPrivateKey, err := crypto.HexToECDSA(os.Getenv("WATCHTOWER_PRIVATE_KEY")) + watchtowerPrivateKey, err := utils.LoadECDSAKey(cli, "WATCHTOWER_PRIVATE_KEY") if err != nil { - return fmt.Errorf("invalid WATCHTOWER_PRIVATE_KEY env var: %w", err) + return fmt.Errorf("loading watchtower private key: %w", err) } // look up operator contract associated with this id diff --git a/sample.env b/sample.env index 25b8e36..e9b5564 100644 --- a/sample.env +++ b/sample.env @@ -1,3 +1,34 @@ -export PRIVATE_KEY="" +# RPC endpoint for blockchain interaction export RPC_URL="" + +# ================================================================================ +# ECDSA Private Key Configuration +# ================================================================================ +# +# SECURITY NOTICE: We strongly recommend using encrypted keystore files instead +# of environment variables for better security. +# +# NEW APPROACH (Recommended): Use --ecdsa-keystore and --ecdsa-password flags +# Example: +# ./avs-cli arpa register-node \ +# --operator-id 1 \ +# --dkg-public-key "0x..." \ +# --registration-signature input.json \ +# --ecdsa-keystore /path/to/keystore.json \ +# --ecdsa-password "your_password" +# +# LEGACY APPROACH (Deprecated): Environment variables (less secure) +# Only use if you cannot migrate to keystore files yet. +# ================================================================================ + +# Node operator private key (used by ARPA register-node command) +# DEPRECATED: Use --ecdsa-keystore flag instead +export PRIVATE_KEY="" + +# Admin EIP-1271 signing key (used by admin registration commands) +# DEPRECATED: Use --ecdsa-keystore flag instead export ADMIN_1271_SIGNING_KEY="" + +# Witness chain watchtower private key (used by witness-chain prepare-registration) +# DEPRECATED: Use --ecdsa-keystore flag instead +export WATCHTOWER_PRIVATE_KEY="" diff --git a/src/keystore/keystore_test.go b/src/keystore/keystore_test.go index 35e0168..f702137 100644 --- a/src/keystore/keystore_test.go +++ b/src/keystore/keystore_test.go @@ -4,10 +4,12 @@ import ( "encoding/json" "fmt" "math/big" + "strings" "testing" "github.com/consensys/gnark-crypto/ecc/bn254" "github.com/consensys/gnark-crypto/ecc/bn254/fp" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" ) @@ -78,3 +80,45 @@ func TestKeystore_BLSSign(t *testing.T) { // input should be [32]byte format but how get I get this? //signature.Verify(blsKeyPair.GetPubKeyG2(), ) } + +func TestKeystore_LoadECDSA(t *testing.T) { + // fixtures + ecdsaKeyFile := "./fixtures/fixture.ecdsa.key.json" + expectedAddress := "0x287B703F25CE707D7974D26DBE5B78121f70794f" + passwd := "fixture@test1234" + + ks := NewKeystoreV3() + ecdsaKey, err := ks.LoadECDSA(ecdsaKeyFile, passwd) + + assert.Nil(t, err) + assert.NotNil(t, ecdsaKey) + + // Verify the loaded key generates the expected address + address := crypto.PubkeyToAddress(ecdsaKey.PublicKey) + assert.Equal(t, strings.ToLower(expectedAddress), strings.ToLower(address.Hex())) + + fmt.Printf("Loaded ECDSA key for address: %s\n", address.Hex()) +} + +func TestKeystore_LoadECDSA_InvalidPassword(t *testing.T) { + ecdsaKeyFile := "./fixtures/fixture.ecdsa.key.json" + wrongPasswd := "wrongpassword" + + ks := NewKeystoreV3() + ecdsaKey, err := ks.LoadECDSA(ecdsaKeyFile, wrongPasswd) + + assert.NotNil(t, err) + assert.Nil(t, ecdsaKey) + assert.Contains(t, err.Error(), "could not decrypt key") +} + +func TestKeystore_LoadECDSA_NonexistentFile(t *testing.T) { + nonexistentFile := "./fixtures/nonexistent.json" + passwd := "fixture@test1234" + + ks := NewKeystoreV3() + ecdsaKey, err := ks.LoadECDSA(nonexistentFile, passwd) + + assert.NotNil(t, err) + assert.Nil(t, ecdsaKey) +} diff --git a/src/utils/ecdsa_loader.go b/src/utils/ecdsa_loader.go new file mode 100644 index 0000000..0b5dc0e --- /dev/null +++ b/src/utils/ecdsa_loader.go @@ -0,0 +1,64 @@ +package utils + +import ( + "crypto/ecdsa" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/etherfi-protocol/etherfi-avs-operator-tool/src/keystore" + "github.com/urfave/cli/v3" +) + +// CommandStringGetter interface for getting string values from CLI commands +type CommandStringGetter interface { + String(name string) string +} + +// LoadECDSAKey loads an ECDSA private key from keystore file if provided, +// otherwise falls back to environment variable for backward compatibility +func LoadECDSAKey(cmd CommandStringGetter, envVarName string) (*ecdsa.PrivateKey, error) { + keystoreFile := cmd.String("ecdsa-keystore") + keystorePassword := cmd.String("ecdsa-password") + + // If keystore flags are provided, load from keystore + if keystoreFile != "" { + if keystorePassword == "" { + return nil, fmt.Errorf("--ecdsa-password is required when --ecdsa-keystore is provided") + } + + ks := keystore.NewKeystoreV3() + privateKey, err := ks.LoadECDSA(keystoreFile, keystorePassword) + if err != nil { + return nil, fmt.Errorf("loading ECDSA key from keystore: %w", err) + } + return privateKey, nil + } + + // Fall back to environment variable for backward compatibility + privateKeyHex := os.Getenv(envVarName) + if privateKeyHex == "" { + return nil, fmt.Errorf("must provide either --ecdsa-keystore flag or set %s environment variable", envVarName) + } + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("invalid private key in %s: %w", envVarName, err) + } + + return privateKey, nil +} + +// GetECDSAKeystoreFlags returns the standard ECDSA keystore flags +func GetECDSAKeystoreFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "ecdsa-keystore", + Usage: "Path to ECDSA keystore file (alternative to environment variable)", + }, + &cli.StringFlag{ + Name: "ecdsa-password", + Usage: "Password for ECDSA keystore file", + }, + } +} \ No newline at end of file diff --git a/src/utils/ecdsa_loader_test.go b/src/utils/ecdsa_loader_test.go new file mode 100644 index 0000000..afdcd9b --- /dev/null +++ b/src/utils/ecdsa_loader_test.go @@ -0,0 +1,158 @@ +package utils + +import ( + "os" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" +) + +func TestLoadECDSAKey_FromEnvironmentVariable(t *testing.T) { + // Test private key (this is a test key, not for production use) + testPrivateKeyHex := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + envVar := "TEST_PRIVATE_KEY" + + // Set environment variable + os.Setenv(envVar, testPrivateKeyHex) + defer os.Unsetenv(envVar) + + // Create mock command that returns empty strings for keystore flags + testCmd := &mockCommand{values: map[string]string{}} + + privateKey, err := LoadECDSAKey(testCmd, envVar) + + assert.Nil(t, err) + assert.NotNil(t, privateKey) + + // Verify the loaded key matches the test key + expectedKey, _ := crypto.HexToECDSA(testPrivateKeyHex) + assert.Equal(t, expectedKey.D, privateKey.D) +} + +func TestLoadECDSAKey_FromKeystore(t *testing.T) { + // Create a mock command with keystore flags set + keystorePath := "../keystore/fixtures/fixture.ecdsa.key.json" + keystorePassword := "fixture@test1234" + expectedAddress := "0x287B703F25CE707D7974D26DBE5B78121f70794f" + + testCmd := &mockCommand{values: map[string]string{ + "ecdsa-keystore": keystorePath, + "ecdsa-password": keystorePassword, + }} + + privateKey, err := LoadECDSAKey(testCmd, "UNUSED_ENV_VAR") + + assert.Nil(t, err) + assert.NotNil(t, privateKey) + + // Verify the loaded key generates the expected address + address := crypto.PubkeyToAddress(privateKey.PublicKey) + assert.Equal(t, strings.ToLower(expectedAddress), strings.ToLower(address.Hex())) +} + +func TestLoadECDSAKey_KeystoreWithoutPassword(t *testing.T) { + keystorePath := "../keystore/fixtures/fixture.ecdsa.key.json" + + // Mock command with keystore but no password + testCmd := &mockCommand{values: map[string]string{ + "ecdsa-keystore": keystorePath, + }} + + privateKey, err := LoadECDSAKey(testCmd, "UNUSED_ENV_VAR") + + assert.NotNil(t, err) + assert.Nil(t, privateKey) + assert.Contains(t, err.Error(), "--ecdsa-password is required") +} + +func TestLoadECDSAKey_InvalidKeystoreFile(t *testing.T) { + invalidKeystorePath := "../keystore/fixtures/nonexistent.json" + keystorePassword := "fixture@test1234" + + testCmd := &mockCommand{values: map[string]string{ + "ecdsa-keystore": invalidKeystorePath, + "ecdsa-password": keystorePassword, + }} + + privateKey, err := LoadECDSAKey(testCmd, "UNUSED_ENV_VAR") + + assert.NotNil(t, err) + assert.Nil(t, privateKey) + assert.Contains(t, err.Error(), "loading ECDSA key from keystore") +} + +func TestLoadECDSAKey_InvalidPassword(t *testing.T) { + keystorePath := "../keystore/fixtures/fixture.ecdsa.key.json" + wrongPassword := "wrongpassword" + + testCmd := &mockCommand{values: map[string]string{ + "ecdsa-keystore": keystorePath, + "ecdsa-password": wrongPassword, + }} + + privateKey, err := LoadECDSAKey(testCmd, "UNUSED_ENV_VAR") + + assert.NotNil(t, err) + assert.Nil(t, privateKey) + assert.Contains(t, err.Error(), "loading ECDSA key from keystore") +} + +func TestLoadECDSAKey_NoKeystoreNoEnvVar(t *testing.T) { + envVar := "NONEXISTENT_ENV_VAR" + + // Ensure env var is not set + os.Unsetenv(envVar) + + testCmd := &mockCommand{values: map[string]string{}} + + privateKey, err := LoadECDSAKey(testCmd, envVar) + + assert.NotNil(t, err) + assert.Nil(t, privateKey) + assert.Contains(t, err.Error(), "must provide either --ecdsa-keystore flag or set") +} + +func TestLoadECDSAKey_InvalidEnvironmentVariable(t *testing.T) { + invalidPrivateKeyHex := "invalidhex" + envVar := "INVALID_PRIVATE_KEY" + + os.Setenv(envVar, invalidPrivateKeyHex) + defer os.Unsetenv(envVar) + + testCmd := &mockCommand{values: map[string]string{}} + + privateKey, err := LoadECDSAKey(testCmd, envVar) + + assert.NotNil(t, err) + assert.Nil(t, privateKey) + assert.Contains(t, err.Error(), "invalid private key") +} + +func TestGetECDSAKeystoreFlags(t *testing.T) { + flags := GetECDSAKeystoreFlags() + + assert.Len(t, flags, 2) + + // Check that the flags have the expected names + flagNames := make([]string, len(flags)) + for i, flag := range flags { + flagNames[i] = flag.Names()[0] + } + + assert.Contains(t, flagNames, "ecdsa-keystore") + assert.Contains(t, flagNames, "ecdsa-password") +} + +// Mock implementation for testing +type mockCommand struct { + values map[string]string +} + +func (mc *mockCommand) String(name string) string { + if val, exists := mc.values[name]; exists { + return val + } + return "" +} \ No newline at end of file