diff --git a/tools/keystores/BUILD.bazel b/tools/keystores/BUILD.bazel new file mode 100644 index 0000000000..eda307ffd3 --- /dev/null +++ b/tools/keystores/BUILD.bazel @@ -0,0 +1,43 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_test") +load("@prysm//tools/go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "github.com/prysmaticlabs/prysm/tools/keystores", + visibility = ["//visibility:private"], + deps = [ + "//shared/bls:go_default_library", + "//shared/params:go_default_library", + "//shared/promptutil:go_default_library", + "//validator/keymanager/v2:go_default_library", + "@com_github_google_uuid//:go_default_library", + "@com_github_logrusorgru_aurora//:go_default_library", + "@com_github_pkg_errors//:go_default_library", + "@com_github_urfave_cli_v2//:go_default_library", + "@com_github_wealdtech_go_eth2_wallet_encryptor_keystorev4//:go_default_library", + ], +) + +go_binary( + name = "keystores", + embed = [":go_default_library"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["main_test.go"], + embed = [":go_default_library"], + deps = [ + "//shared/bls:go_default_library", + "//shared/params:go_default_library", + "//shared/testutil:go_default_library", + "//shared/testutil/assert:go_default_library", + "//shared/testutil/require:go_default_library", + "//validator/keymanager/v2:go_default_library", + "@com_github_google_uuid//:go_default_library", + "@com_github_urfave_cli_v2//:go_default_library", + "@com_github_wealdtech_go_eth2_wallet_encryptor_keystorev4//:go_default_library", + ], +) diff --git a/tools/keystores/main.go b/tools/keystores/main.go new file mode 100644 index 0000000000..dab2f3d5e3 --- /dev/null +++ b/tools/keystores/main.go @@ -0,0 +1,308 @@ +// This tool allows for simple encrypting and decrypting of EIP-2335 compliant, BLS12-381 +// keystore.json files which as password protected. This is helpful in development to inspect +// the contents of keystores created by eth2 wallets or to easily produce keystores from a +// specified secret to move them around in a standard format between eth2 clients. +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/user" + "path" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/logrusorgru/aurora" + "github.com/pkg/errors" + "github.com/prysmaticlabs/prysm/shared/bls" + "github.com/prysmaticlabs/prysm/shared/params" + "github.com/prysmaticlabs/prysm/shared/promptutil" + v2keymanager "github.com/prysmaticlabs/prysm/validator/keymanager/v2" + "github.com/urfave/cli/v2" + keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" +) + +var ( + keystoresFlag = &cli.StringFlag{ + Name: "keystores", + Value: "", + Usage: "Path to a file or directory containing keystore files", + Required: true, + } + passwordFlag = &cli.StringFlag{ + Name: "password", + Value: "", + Usage: "Password for the keystore(s)", + } + privateKeyFlag = &cli.StringFlag{ + Name: "private-key", + Value: "", + Usage: "Hex string for the BLS12-381 private key you wish encrypt into a keystore file", + Required: true, + } + outputPathFlag = &cli.StringFlag{ + Name: "output-path", + Value: "", + Usage: "Output path to write the newly encrypted keystore file", + Required: true, + } + au = aurora.NewAurora(true /* enable colors */) +) + +func main() { + app := &cli.App{ + Name: "Keystore utility", + Description: "Utility to encrypt and decrypt EIP-2335 compliant keystore.json files for BLS12-381 private keys", + Usage: "", + Commands: []*cli.Command{ + { + Name: "decrypt", + Usage: "decrypt a specified keystore file or directory containing keystore files", + Flags: []cli.Flag{ + keystoresFlag, + passwordFlag, + }, + Action: decrypt, + }, + { + Name: "encrypt", + Usage: "encrypt a specified hex value of a BLS12-381 private key into a keystore file", + Flags: []cli.Flag{ + passwordFlag, + privateKeyFlag, + outputPathFlag, + }, + Action: encrypt, + }, + }, + } + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +} + +func decrypt(cliCtx *cli.Context) error { + keystorePath := cliCtx.String(keystoresFlag.Name) + if keystorePath == "" { + return errors.New("--keystore must be set") + } + fullPath, err := expandPath(keystorePath) + if err != nil { + return errors.Wrapf(err, "could not expand path: %s", keystorePath) + } + password := cliCtx.String(passwordFlag.Name) + isPasswordSet := cliCtx.IsSet(passwordFlag.Name) + if !isPasswordSet { + password, err = promptutil.PasswordPrompt("Input the keystore(s) password", func(s string) error { + // Any password is valid. + return nil + }) + } + isDir, err := hasDir(fullPath) + if err != nil { + return errors.Wrapf(err, "could not check if path exists: %s", fullPath) + } + if isDir { + files, err := ioutil.ReadDir(fullPath) + if err != nil { + return errors.Wrapf(err, "could not read directory: %s", fullPath) + } + for _, f := range files { + if f.IsDir() { + continue + } + keystorePath := filepath.Join(fullPath, f.Name()) + if err := readAndDecryptKeystore(keystorePath, password); err != nil { + fmt.Printf("could not read nor decrypt keystore at path %s: %v\n", keystorePath, err) + } + } + return nil + } + return readAndDecryptKeystore(fullPath, password) +} + +// Attempts to encrypt a passed-in BLS12-3381 private key into the EIP-2335 +// keystore.json format. If a file at the specified output path exists, asks the user +// to confirm overwriting its contents. If the value passed in is not a valid BLS12-381 +// private key, the function will fail. +func encrypt(cliCtx *cli.Context) error { + var err error + password := cliCtx.String(passwordFlag.Name) + isPasswordSet := cliCtx.IsSet(passwordFlag.Name) + if !isPasswordSet { + password, err = promptutil.PasswordPrompt("Input the keystore(s) password", func(s string) error { + // Any password is valid. + return nil + }) + } + privateKeyString := cliCtx.String(privateKeyFlag.Name) + if privateKeyString == "" { + return errors.New("--private-key must not be empty") + } + outputPath := cliCtx.String(outputPathFlag.Name) + if outputPath == "" { + return errors.New("--output-path must be set") + } + fullPath, err := expandPath(outputPath) + if err != nil { + return errors.Wrapf(err, "could not expand path: %s", outputPath) + } + exists, err := fileExists(fullPath) + if err != nil { + return errors.Wrapf(err, "could not check if file exists: %s", fullPath) + } + if exists { + response, err := promptutil.ValidatePrompt( + fmt.Sprintf("file at path %s already exists, are you sure you want to overwrite it? [y/n]", fullPath), + func(s string) error { + input := strings.ToLower(s) + if input != "y" && input != "n" { + return errors.New("please confirm the above text") + } + return nil + }, + ) + if err != nil { + return errors.Wrap(err, "could not validate prompt confirmation") + } + if response == "n" { + return nil + } + } + if len(privateKeyString) > 2 && strings.Contains(privateKeyString, "0x") { + privateKeyString = privateKeyString[2:] // Strip the 0x prefix, if any. + } + bytesValue, err := hex.DecodeString(privateKeyString) + if err != nil { + return errors.Wrapf(err, "could not decode as hex string: %s", privateKeyString) + } + privKey, err := bls.SecretKeyFromBytes(bytesValue) + if err != nil { + return errors.Wrap(err, "not a valid BLS12-381 private key") + } + pubKey := fmt.Sprintf("%x", privKey.PublicKey().Marshal()) + encryptor := keystorev4.New() + id, err := uuid.NewRandom() + if err != nil { + return errors.Wrap(err, "could not generate new random uuid") + } + cryptoFields, err := encryptor.Encrypt(bytesValue, password) + if err != nil { + return errors.Wrap(err, "could not encrypt into new keystore") + } + item := &v2keymanager.Keystore{ + Crypto: cryptoFields, + ID: id.String(), + Version: encryptor.Version(), + Pubkey: pubKey, + Name: encryptor.Name(), + } + encodedFile, err := json.MarshalIndent(item, "", "\t") + if err != nil { + return errors.Wrap(err, "could not json marshal keystore") + } + if err := ioutil.WriteFile(fullPath, encodedFile, params.BeaconIoConfig().ReadWritePermissions); err != nil { + return errors.Wrapf(err, "could not write file at path: %s", fullPath) + } + fmt.Printf( + "\nWrote encrypted keystore file at path %s\n", + au.BrightMagenta(fullPath), + ) + fmt.Printf("Pubkey: %s\n", au.BrightGreen( + fmt.Sprintf("%#x", privKey.PublicKey().Marshal()), + )) + return nil +} + +// Reads the keystore file at the provided path and attempts +// to decrypt it with the specified passwords. +func readAndDecryptKeystore(fullPath string, password string) error { + file, err := ioutil.ReadFile(fullPath) + if err != nil { + return errors.Wrapf(err, "could not read file at path: %s", fullPath) + } + decryptor := keystorev4.New() + keystoreFile := &v2keymanager.Keystore{} + + if err := json.Unmarshal(file, keystoreFile); err != nil { + return errors.Wrap(err, "could not JSON unmarshal keystore file") + } + // We extract the validator signing private key from the keystore + // by utilizing the password. + privKeyBytes, err := decryptor.Decrypt(keystoreFile.Crypto, password) + if err != nil { + if strings.Contains(err.Error(), "invalid checksum") { + return fmt.Errorf("incorrect password for keystore at path: %s", fullPath) + } + return err + } + publicKeyBytes, err := hex.DecodeString(keystoreFile.Pubkey) + if err != nil { + return errors.Wrapf(err, "could not parse public key for keystore at path: %s", fullPath) + } + fmt.Printf("\nDecrypted keystore %s\n", au.BrightMagenta(fullPath)) + fmt.Printf("Privkey: %#x\n", au.BrightGreen(privKeyBytes)) + fmt.Printf("Pubkey: %#x\n", au.BrightGreen(publicKeyBytes)) + return nil +} + +// Checks if the item at the specified path exists and is a directory. +func hasDir(dirPath string) (bool, error) { + fullPath, err := expandPath(dirPath) + if err != nil { + return false, err + } + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return info.IsDir(), nil +} + +// Check if a file at the specified path exists. +func fileExists(filePath string) (bool, error) { + fullPath, err := expandPath(filePath) + if err != nil { + return false, err + } + if _, err := os.Stat(fullPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// Expands a file path +// 1. replace tilde with users home dir +// 2. expands embedded environment variables +// 3. cleans the path, e.g. /a/b/../c -> /a/c +// Note, it has limitations, e.g. ~someuser/tmp will not be expanded +func expandPath(p string) (string, error) { + if strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") { + if home := homeDir(); home != "" { + p = home + p[1:] + } + } + return filepath.Abs(path.Clean(os.ExpandEnv(p))) +} + +func homeDir() string { + if home := os.Getenv("HOME"); home != "" { + return home + } + if usr, err := user.Current(); err == nil { + return usr.HomeDir + } + return "" +} diff --git a/tools/keystores/main_test.go b/tools/keystores/main_test.go new file mode 100644 index 0000000000..e7eb54f90a --- /dev/null +++ b/tools/keystores/main_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/prysmaticlabs/prysm/shared/bls" + "github.com/prysmaticlabs/prysm/shared/params" + "github.com/prysmaticlabs/prysm/shared/testutil" + "github.com/prysmaticlabs/prysm/shared/testutil/assert" + "github.com/prysmaticlabs/prysm/shared/testutil/require" + v2keymanager "github.com/prysmaticlabs/prysm/validator/keymanager/v2" + "github.com/urfave/cli/v2" + keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" +) + +type cliConfig struct { + keystoresPath string + password string + privateKey string + outputPath string +} + +func setupCliContext( + tb testing.TB, + conf *cliConfig, +) *cli.Context { + app := cli.App{} + set := flag.NewFlagSet("test", 0) + set.String(keystoresFlag.Name, conf.keystoresPath, "") + set.String(passwordFlag.Name, conf.password, "") + set.String(privateKeyFlag.Name, conf.privateKey, "") + set.String(outputPathFlag.Name, conf.outputPath, "") + assert.NoError(tb, set.Set(keystoresFlag.Name, conf.keystoresPath)) + assert.NoError(tb, set.Set(passwordFlag.Name, conf.password)) + assert.NoError(tb, set.Set(privateKeyFlag.Name, conf.privateKey)) + assert.NoError(tb, set.Set(outputPathFlag.Name, conf.outputPath)) + return cli.NewContext(&app, set, nil) +} + +func createRandomKeystore(t testing.TB, password string) (*v2keymanager.Keystore, bls.SecretKey) { + encryptor := keystorev4.New() + id, err := uuid.NewRandom() + require.NoError(t, err) + validatingKey := bls.RandKey() + pubKey := validatingKey.PublicKey().Marshal() + cryptoFields, err := encryptor.Encrypt(validatingKey.Marshal(), password) + require.NoError(t, err) + return &v2keymanager.Keystore{ + Crypto: cryptoFields, + Pubkey: fmt.Sprintf("%x", pubKey), + ID: id.String(), + Version: encryptor.Version(), + Name: encryptor.Name(), + }, validatingKey +} + +func setupRandomDir(t testing.TB) string { + randPath, err := rand.Int(rand.Reader, big.NewInt(1000000)) + require.NoError(t, err) + randDir := filepath.Join(testutil.TempDir(), fmt.Sprintf("/%d", randPath)) + require.NoError(t, os.MkdirAll(randDir, os.ModePerm)) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(randDir), "Failed to remove directory") + }) + return randDir +} + +func TestDecrypt(t *testing.T) { + keystoresDir := setupRandomDir(t) + password := "secretPassw0rd$1999" + keystore, privKey := createRandomKeystore(t, password) + // We write a random keystore to a keystores directory. + encodedKeystore, err := json.MarshalIndent(keystore, "", "\t") + require.NoError(t, err) + keystoreFilePath := filepath.Join(keystoresDir, "keystore.json") + require.NoError(t, ioutil.WriteFile( + keystoreFilePath, encodedKeystore, params.BeaconIoConfig().ReadWritePermissions), + ) + + cliCtx := setupCliContext(t, &cliConfig{ + keystoresPath: keystoreFilePath, + password: password, + }) + + rescueStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + // We attempt to decrypt the keystore file we just wrote to disk. + require.NoError(t, decrypt(cliCtx)) + + require.NoError(t, w.Close()) + out, err := ioutil.ReadAll(r) + require.NoError(t, err) + + // We capture output from stdout. + os.Stdout = rescueStdout + stringOutput := string(out) + + // We capture the results of stdout to check the public key and private keys + // were both printed to stdout. + assert.Equal(t, strings.Contains(stringOutput, keystore.Pubkey), true) + assert.Equal(t, strings.Contains(stringOutput, fmt.Sprintf("%#x", privKey.Marshal())), true) +} + +func TestEncrypt(t *testing.T) { + keystoresDir := setupRandomDir(t) + password := "secretPassw0rd$1999" + keystoreFilePath := filepath.Join(keystoresDir, "keystore.json") + privKey := bls.RandKey() + + cliCtx := setupCliContext(t, &cliConfig{ + outputPath: keystoreFilePath, + password: password, + privateKey: fmt.Sprintf("%#x", privKey.Marshal()), + }) + + rescueStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + // We attempt to encrypt the secret key and save it to the output path. + require.NoError(t, encrypt(cliCtx)) + + require.NoError(t, w.Close()) + out, err := ioutil.ReadAll(r) + require.NoError(t, err) + + // We capture output from stdout. + os.Stdout = rescueStdout + stringOutput := string(out) + + // We capture the results of stdout to check the public key was printed to stdout. + assert.Equal( + t, + strings.Contains(stringOutput, fmt.Sprintf("%x", privKey.PublicKey().Marshal())), + true, + ) +} diff --git a/validator/keymanager/v2/BUILD.bazel b/validator/keymanager/v2/BUILD.bazel index b7493ebd4c..82ac44e0bf 100644 --- a/validator/keymanager/v2/BUILD.bazel +++ b/validator/keymanager/v2/BUILD.bazel @@ -8,6 +8,7 @@ go_library( visibility = [ "//validator:__pkg__", "//validator:__subpackages__", + "//tools/keystores:__pkg__", ], deps = [ "//proto/validator/accounts/v2:go_default_library",