prysm-pulse/validator/accounts/accounts_import.go
Manu NALEPA ef21d3adf8
Implement EIP-3076 minimal slashing protection, using a filesystem database (#13360)
* `EpochFromString`: Use already defined `Uint64FromString` function.

* `Test_uint64FromString` => `Test_FromString`

This test function tests more functions than `Uint64FromString`.

* Slashing protection history: Remove unreachable code.

The function `NewKVStore` creates, via `kv.UpdatePublicKeysBuckets`,
a new item in the `proposal-history-bucket-interchange`.

IMO there is no real reason to prefer `proposal` than `attestation`
as a prefix for this bucket, but this is the way it is done right now
and renaming the bucket will probably be backward incompatible.

An `attestedPublicKey` cannot exist without
the corresponding `proposedPublicKey`.

Thus, the `else` portion of code removed in this commit is not reachable.
We raise an error if we get there.

This is also probably the reason why the removed `else` portion was not
tested.

* `NewKVStore`: Switch items in `createBuckets`.

So the order corresponds to `schema.go`

* `slashableAttestationCheck`: Fix comments and logs.

* `ValidatorClient.db`: Use `iface.ValidatorDB`.

* BoltDB database: Implement `GraffitiFileHash`.

* Filesystem database: Creates `db.go`.

This file defines the following structs:
- `Store`
- `Graffiti`
- `Configuration`
- `ValidatorSlashingProtection`

This files implements the following public functions:
- `NewStore`
- `Close`
- `Backup`
- `DatabasePath`
- `ClearDB`
- `UpdatePublicKeysBuckets`

This files implements the following private functions:
- `slashingProtectionDirPath`
- `configurationFilePath`
- `configuration`
- `saveConfiguration`
- `validatorSlashingProtection`
- `saveValidatorSlashingProtection`
- `publicKeys`

* Filesystem database: Creates `genesis.go`.

This file defines the following public functions:
- `GenesisValidatorsRoot`
- `SaveGenesisValidatorsRoot`

* Filesystem database: Creates `graffiti.go`.

This file defines the following public functions:
- `SaveGraffitiOrderedIndex`
- `GraffitiOrderedIndex`

* Filesystem database: Creates `migration.go`.

This file defines the following public functions:
- `RunUpMigrations`
- `RunDownMigrations`

* Filesystem database: Creates proposer_settings.go.

This file defines the following public functions:
- `ProposerSettings`
- `ProposerSettingsExists`
- `SaveProposerSettings`

* Filesystem database: Creates `attester_protection.go`.

This file defines the following public functions:
- `EIPImportBlacklistedPublicKeys`
- `SaveEIPImportBlacklistedPublicKeys`
- `SigningRootAtTargetEpoch`
- `LowestSignedTargetEpoch`
- `LowestSignedSourceEpoch`
- `AttestedPublicKeys`
- `CheckSlashableAttestation`
- `SaveAttestationForPubKey`
- `SaveAttestationsForPubKey`
- `AttestationHistoryForPubKey`

* Filesystem database: Creates `proposer_protection.go`.

This file defines the following public functions:
- `HighestSignedProposal`
- `LowestSignedProposal`
- `ProposalHistoryForPubKey`
- `ProposalHistoryForSlot`
- `ProposedPublicKeys`

* Ensure that the filesystem store implements the `ValidatorDB` interface.

* `slashableAttestationCheck`: Check the database type.

* `slashableProposalCheck`: Check the database type.

* `slashableAttestationCheck`: Allow usage of minimal slashing protection.

* `slashableProposalCheck`: Allow usage of minimal slashing protection.

* `ImportStandardProtectionJSON`: Check the database type.

* `ImportStandardProtectionJSON`: Allow usage of min slashing protection.

* Implement `RecursiveDirFind`.

* Implement minimal<->complete DB conversion.

3 public functions are implemented:
- `IsCompleteDatabaseExisting`
- `IsMinimalDatabaseExisting`
- `ConvertDatabase`

* `setupDB`: Add `isSlashingProtectionMinimal` argument.

The feature addition is located in `validator/node/node_test.go`.
The rest of this commit consists in minimal slashing protection testing.

* `setupWithKey`: Add `isSlashingProtectionMinimal` argument.

The feature addition is located in `validator/client/propose_test.go`.

The rest of this commit consists in tests wrapping.

* `setup`: Add `isSlashingProtectionMinimal` argument.

The added feature is located in the `validator/client/propose_test.go`
file.

The rest of this commit consists in tests wrapping.

* `initializeFromCLI` and `initializeForWeb`: Factorize db init.

* Add `convert-complete-to-minimal` command.

* Creates `--enable-minimal-slashing-protection` flag.

* `importSlashingProtectionJSON`: Check database type.

* `exportSlashingProtectionJSON`: Check database type.

* `TestClearDB`: Test with minimal slashing protection.

* KeyManager: Test with minimal slashing protection.

* RPC: KeyManager: Test with minimal slashing protection.

* `convert-complete-to-minimal`: Change option names.

Options were:
- `--source` (for source data directory), and
- `--target` (for target data directory)

However, since this command deals with slashing protection, which has
source (epochs) and target (epochs), the initial option names may confuse
the user.

In this commit:
`--source` ==> `--source-data-dir`
`--target` ==> `--target-data-dir`

* Set `SlashableAttestationCheck` as an iface method.

And delete `CheckSlashableAttestation` from iface.

* Move helpers functions in a more general directory.

No functional change.

* Extract common structs out of `kv`.

==> `filesystem` does not depend anymore on `kv`.
==> `iface` does not depend anymore on `kv`.
==> `slashing-protection` does not depend anymore on `kv`.

* Move `ValidateMetadata` in `validator/helpers`.

* `ValidateMetadata`: Test with mock.

This way, we can:
- Avoid any circular import for tests.
- Implement once for all `iface.ValidatorDB` implementations
  the `ValidateMetadata`function.
- Have tests (and coverage) of `ValidateMetadata`in
  its own package.

The ideal solution would have been to implement `ValidateMetadata` as
a method with the `iface.ValidatorDB`receiver.
Unfortunately, golang does not allow that.

* `iface.ValidatorDB`: Implement ImportStandardProtectionJSON.

The whole purpose of this commit is to avoid the `switch validatorDB.(type)`
in `ImportStandardProtectionJSON`.

* `iface.ValidatorDB`: Implement `SlashableProposalCheck`.

* Remove now useless `slashableProposalCheck`.

* Delete useless `ImportStandardProtectionJSON`.

* `file.Exists`: Detect directories and return an error.

Before, `Exists` was only able to detect if a file exists.
Now, this function takes an extra `File` or `Directory` argument.
It detects either if a file or a directory exists.

Before, if an error was returned by `os.Stat`, the the file was
considered as non existing.
Now, it is treated as a real error.

* Replace `os.Stat` by `file.Exists`.

* Remove `Is{Complete,Minimal}DatabaseExisting`.

* `publicKeys`: Add log if unexpected file found.

* Move `{Source,Target}DataDirFlag`in `db.go`.

* `failedAttLocalProtectionErr`: `var`==> `const`

* `signingRoot`: `32`==> `fieldparams.RootLength`.

* `validatorClientData`==> `validator-client-data`.

To be consistent with `slashing-protection`.

* Add progress bars for `import` and `convert`.

* `parseBlocksForUniquePublicKeys`: Move in `db/kv`.

* helpers: Remove unused `initializeProgressBar` function.
2024-03-05 15:27:15 +00:00

337 lines
11 KiB
Go

package accounts
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v5/crypto/bls"
"github.com/prysmaticlabs/prysm/v5/encoding/bytesutil"
"github.com/prysmaticlabs/prysm/v5/io/file"
"github.com/prysmaticlabs/prysm/v5/io/prompt"
"github.com/prysmaticlabs/prysm/v5/validator/accounts/wallet"
"github.com/prysmaticlabs/prysm/v5/validator/keymanager"
keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4"
)
var derivationPathRegex = regexp.MustCompile(`m_12381_3600_(\d+)_(\d+)_(\d+)`)
// byDerivationPath implements sort.Interface based on a
// derivation path present in a keystore filename, if any. This
// will allow us to sort filenames such as keystore-m_12381_3600_1_0_0.json
// in a directory and import them nicely in order of the derivation path.
type byDerivationPath []string
// Len is the number of elements in the collection.
func (fileNames byDerivationPath) Len() int { return len(fileNames) }
// Less reports whether the element with index i must sort before the element with index j.
func (fileNames byDerivationPath) Less(i, j int) bool {
// We check if file name at index i has a derivation path
// in the filename. If it does not, then it is not less than j, and
// we should swap it towards the end of the sorted list.
if !derivationPathRegex.MatchString(fileNames[i]) {
return false
}
derivationPathA := derivationPathRegex.FindString(fileNames[i])
derivationPathB := derivationPathRegex.FindString(fileNames[j])
if derivationPathA == "" {
return false
}
if derivationPathB == "" {
return true
}
a, err := strconv.Atoi(accountIndexFromFileName(derivationPathA))
if err != nil {
return false
}
b, err := strconv.Atoi(accountIndexFromFileName(derivationPathB))
if err != nil {
return false
}
return a < b
}
// Swap swaps the elements with indexes i and j.
func (fileNames byDerivationPath) Swap(i, j int) {
fileNames[i], fileNames[j] = fileNames[j], fileNames[i]
}
// ImportAccountsConfig defines values to run the import accounts function.
type ImportAccountsConfig struct {
Keystores []*keymanager.Keystore
Importer keymanager.Importer
AccountPassword string
}
// Import can import external, EIP-2335 compliant keystore.json files as
// new accounts into the Prysm validator wallet. This uses the CLI to extract
// values necessary to run the function.
func (acm *CLIManager) Import(ctx context.Context) error {
k, ok := acm.keymanager.(keymanager.Importer)
if !ok {
return errors.New("keymanager cannot import keystores")
}
log.Info("importing validator keystores...")
// Check if the user wishes to import a one-off, private key directly
// as an account into the Prysm validator.
if acm.importPrivateKeys {
return importPrivateKeyAsAccount(ctx, acm.wallet, k, acm.privateKeyFile)
}
keystoresImported, err := processDirectory(ctx, acm.keysDir, 0)
if err != nil {
return errors.Wrap(err, "unable to process directory and import keys")
}
var accountsPassword string
if acm.readPasswordFile {
data, err := os.ReadFile(acm.passwordFilePath) // #nosec G304
if err != nil {
return err
}
accountsPassword = string(data)
} else {
accountsPassword, err = prompt.PasswordPrompt(
"Enter the password for your imported accounts", prompt.NotEmpty,
)
if err != nil {
return fmt.Errorf("could not read account password: %w", err)
}
}
fmt.Println("Importing accounts, this may take a while...")
statuses, err := ImportAccounts(ctx, &ImportAccountsConfig{
Importer: k,
Keystores: keystoresImported,
AccountPassword: accountsPassword,
})
if err != nil {
return err
}
var successfullyImportedAccounts []string
for i, status := range statuses {
switch status.Status {
case keymanager.StatusImported:
successfullyImportedAccounts = append(successfullyImportedAccounts, keystoresImported[i].Pubkey)
case keymanager.StatusDuplicate:
log.Warnf("Duplicate key %s found in import request, skipped", keystoresImported[i].Pubkey)
case keymanager.StatusError:
log.Warnf("Could not import keystore for %s: %s", keystoresImported[i].Pubkey, status.Message)
}
}
if len(successfullyImportedAccounts) == 0 {
log.Error("no accounts were successfully imported")
} else {
log.Infof(
"Imported accounts %v, view all of them by running `accounts list`",
successfullyImportedAccounts,
)
}
return nil
}
// Recursive function to process directories and files.
func processDirectory(ctx context.Context, dir string, depth int) ([]*keymanager.Keystore, error) {
maxdepth := 2
if depth > maxdepth {
log.Infof("stopped checking folders for keystores after max depth of %d was reached", maxdepth)
return nil, nil // Stop recursion after two levels.
}
log.Infof("checking directory for keystores: %s", dir)
isDir, err := file.HasDir(dir)
if err != nil {
return nil, errors.Wrap(err, "could not determine if path is a directory")
}
keystoresImported := make([]*keymanager.Keystore, 0)
if isDir {
files, err := os.ReadDir(dir)
if err != nil {
return nil, errors.Wrap(err, "could not read dir")
}
if len(files) == 0 {
return nil, fmt.Errorf("directory %s has no files, cannot import from it", dir)
}
for _, f := range files {
fullPath := filepath.Join(dir, f.Name())
if f.IsDir() {
subKeystores, err := processDirectory(ctx, fullPath, depth+1)
if err != nil {
return nil, err
}
keystoresImported = append(keystoresImported, subKeystores...)
} else {
keystore, err := readKeystoreFile(ctx, fullPath)
if err != nil {
if strings.Contains(err.Error(), "could not decode keystore json") {
continue
}
return nil, errors.Wrapf(err, "could not import keystore at path: %s", fullPath)
}
keystoresImported = append(keystoresImported, keystore)
}
}
} else {
keystore, err := readKeystoreFile(ctx, dir)
if err != nil {
return nil, errors.Wrap(err, "could not import keystore")
}
keystoresImported = append(keystoresImported, keystore)
}
return keystoresImported, nil
}
// ImportAccounts can import external, EIP-2335 compliant keystore.json files as
// new accounts into the Prysm validator wallet.
func ImportAccounts(ctx context.Context, cfg *ImportAccountsConfig) ([]*keymanager.KeyStatus, error) {
if cfg.AccountPassword == "" {
statuses := make([]*keymanager.KeyStatus, len(cfg.Keystores))
for i, keystore := range cfg.Keystores {
statuses[i] = &keymanager.KeyStatus{
Status: keymanager.StatusError,
Message: fmt.Sprintf(
"account password is required to import keystore %s",
keystore.Pubkey,
),
}
}
return statuses, nil
}
passwords := make([]string, len(cfg.Keystores))
for i := 0; i < len(cfg.Keystores); i++ {
passwords[i] = cfg.AccountPassword
}
return cfg.Importer.ImportKeystores(
ctx,
cfg.Keystores,
passwords,
)
}
// Imports a one-off file containing a private key as a hex string into
// the Prysm validator's accounts.
func importPrivateKeyAsAccount(ctx context.Context, wallet *wallet.Wallet, importer keymanager.Importer, privKeyFile string) error {
fullPath, err := file.ExpandPath(privKeyFile)
if err != nil {
return errors.Wrapf(err, "could not expand file path for %s", privKeyFile)
}
exists, err := file.Exists(fullPath, file.Regular)
if err != nil {
return errors.Wrapf(err, "could not check if file exists: %s", fullPath)
}
if !exists {
return fmt.Errorf("file %s does not exist", fullPath)
}
privKeyHex, err := os.ReadFile(fullPath) // #nosec G304
if err != nil {
return errors.Wrapf(err, "could not read private key file at path %s", fullPath)
}
privKeyString := string(privKeyHex)
if len(privKeyString) > 2 && strings.Contains(privKeyString, "0x") {
privKeyString = privKeyString[2:] // Strip the 0x prefix, if any.
}
privKeyBytes, err := hex.DecodeString(strings.TrimRight(privKeyString, "\r\n"))
if err != nil {
return errors.Wrap(
err, "could not decode file as hex string, does the file contain a valid hex string?",
)
}
privKey, err := bls.SecretKeyFromBytes(privKeyBytes)
if err != nil {
return errors.Wrap(err, "not a valid BLS private key")
}
keystore, err := createKeystoreFromPrivateKey(privKey, wallet.Password())
if err != nil {
return errors.Wrap(err, "could not encrypt private key into a keystore file")
}
statuses, err := ImportAccounts(
ctx,
&ImportAccountsConfig{
Importer: importer,
AccountPassword: wallet.Password(),
Keystores: []*keymanager.Keystore{keystore},
},
)
if err != nil {
return errors.Wrap(err, "could not import keystore into wallet")
}
for _, status := range statuses {
switch status.Status {
case keymanager.StatusImported:
fmt.Printf(
"Imported account with public key %#x, view all accounts by running `accounts list`\n",
au.BrightMagenta(bytesutil.Trunc(privKey.PublicKey().Marshal())),
)
return nil
case keymanager.StatusError:
return fmt.Errorf("could not import keystore for %s: %s", keystore.Pubkey, status.Message)
case keymanager.StatusDuplicate:
return fmt.Errorf("duplicate key %s skipped", keystore.Pubkey)
}
}
return nil
}
func readKeystoreFile(_ context.Context, keystoreFilePath string) (*keymanager.Keystore, error) {
keystoreBytes, err := os.ReadFile(keystoreFilePath) // #nosec G304
if err != nil {
return nil, errors.Wrap(err, "could not read keystore file")
}
keystoreFile := &keymanager.Keystore{}
if err := json.Unmarshal(keystoreBytes, keystoreFile); err != nil {
return nil, errors.Wrap(err, "could not decode keystore json")
}
if keystoreFile.Pubkey == "" {
return nil, errors.New("could not decode keystore json")
}
if keystoreFile.Description == "" && keystoreFile.Name != "" {
keystoreFile.Description = keystoreFile.Name
}
return keystoreFile, nil
}
func createKeystoreFromPrivateKey(privKey bls.SecretKey, walletPassword string) (*keymanager.Keystore, error) {
encryptor := keystorev4.New()
id, err := uuid.NewRandom()
if err != nil {
return nil, err
}
cryptoFields, err := encryptor.Encrypt(privKey.Marshal(), walletPassword)
if err != nil {
return nil, errors.Wrapf(
err,
"could not encrypt private key with public key %#x",
privKey.PublicKey().Marshal(),
)
}
return &keymanager.Keystore{
Crypto: cryptoFields,
ID: id.String(),
Version: encryptor.Version(),
Pubkey: fmt.Sprintf("%x", privKey.PublicKey().Marshal()),
Description: encryptor.Name(),
}, nil
}
// Extracts the account index, j, from a derivation path in a file name
// with the format m_12381_3600_j_0_0.
func accountIndexFromFileName(derivationPath string) string {
derivationPath = derivationPath[13:]
accIndexEnd := strings.Index(derivationPath, "_")
return derivationPath[:accIndexEnd]
}