Add validator-manager (#3502)

## Issue Addressed

Addresses #2557

## Proposed Changes

Adds the `lighthouse validator-manager` command, which provides:

- `lighthouse validator-manager create`
    - Creates a `validators.json` file and a `deposits.json` (same format as https://github.com/ethereum/staking-deposit-cli)
- `lighthouse validator-manager import`
    - Imports validators from a `validators.json` file to the VC via the HTTP API.
- `lighthouse validator-manager move`
    - Moves validators from one VC to the other, utilizing only the VC API.

## Additional Info

In 98bcb947c I've reduced some VC `ERRO` and `CRIT` warnings to `WARN` or `DEBG` for the case where a pubkey is missing from the validator store. These were being triggered when we removed a validator but still had it in caches. It seems to me that `UnknownPubkey` will only happen in the case where we've removed a validator, so downgrading the logs is prudent. All the logs are `DEBG` apart from attestations and blocks which are `WARN`. I thought having *some* logging about this condition might help us down the track.

In https://github.com/sigp/lighthouse/pull/3502/commits/856cd7e37db11e0318dff9db0979d67b79c2a633 I've made the VC delete the corresponding password file when it's deleting a keystore. This seemed like nice hygiene. Notably, it'll only delete that password file after it scans the validator definitions and finds that no other validator is also using that password file.
This commit is contained in:
Paul Hauner
2023-08-08 00:03:22 +00:00
parent 5ea75052a8
commit 1373dcf076
69 changed files with 6060 additions and 745 deletions
+57
View File
@@ -13,6 +13,9 @@ use std::fs::{self, File};
use std::io;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::str::from_utf8;
use std::thread::sleep;
use std::time::Duration;
use zeroize::Zeroize;
pub mod validator_definitions;
@@ -30,6 +33,8 @@ pub const MINIMUM_PASSWORD_LEN: usize = 12;
/// array of length 32.
const DEFAULT_PASSWORD_LEN: usize = 48;
pub const MNEMONIC_PROMPT: &str = "Enter the mnemonic phrase:";
/// Returns the "default" path where a wallet should store its password file.
pub fn default_wallet_password_path<P: AsRef<Path>>(wallet_name: &str, secrets_dir: P) -> PathBuf {
secrets_dir.as_ref().join(format!("{}.pass", wallet_name))
@@ -59,6 +64,18 @@ pub fn read_password<P: AsRef<Path>>(path: P) -> Result<PlainText, io::Error> {
fs::read(path).map(strip_off_newlines).map(Into::into)
}
/// Reads a password file into a `ZeroizeString` struct, with new-lines removed.
pub fn read_password_string<P: AsRef<Path>>(path: P) -> Result<ZeroizeString, String> {
fs::read(path)
.map_err(|e| format!("Error opening file: {:?}", e))
.map(strip_off_newlines)
.and_then(|bytes| {
String::from_utf8(bytes)
.map_err(|e| format!("Error decoding utf8: {:?}", e))
.map(Into::into)
})
}
/// Write a file atomically by using a temporary file as an intermediate.
///
/// Care is taken to preserve the permissions of the file at `file_path` being written.
@@ -220,6 +237,46 @@ impl AsRef<[u8]> for ZeroizeString {
}
}
pub fn read_mnemonic_from_cli(
mnemonic_path: Option<PathBuf>,
stdin_inputs: bool,
) -> Result<Mnemonic, String> {
let mnemonic = match mnemonic_path {
Some(path) => fs::read(&path)
.map_err(|e| format!("Unable to read {:?}: {:?}", path, e))
.and_then(|bytes| {
let bytes_no_newlines: PlainText = strip_off_newlines(bytes).into();
let phrase = from_utf8(bytes_no_newlines.as_ref())
.map_err(|e| format!("Unable to derive mnemonic: {:?}", e))?;
Mnemonic::from_phrase(phrase, Language::English).map_err(|e| {
format!(
"Unable to derive mnemonic from string {:?}: {:?}",
phrase, e
)
})
})?,
None => loop {
eprintln!();
eprintln!("{}", MNEMONIC_PROMPT);
let mnemonic = read_input_from_user(stdin_inputs)?;
match Mnemonic::from_phrase(mnemonic.as_str(), Language::English) {
Ok(mnemonic_m) => {
eprintln!("Valid mnemonic provided.");
eprintln!();
sleep(Duration::from_secs(1));
break mnemonic_m;
}
Err(_) => {
eprintln!("Invalid mnemonic");
}
}
},
};
Ok(mnemonic)
}
#[cfg(test)]
mod test {
use super::*;
@@ -3,7 +3,9 @@
//! Serves as the source-of-truth of which validators this validator client should attempt (or not
//! attempt) to load into the `crate::intialized_validators::InitializedValidators` struct.
use crate::{default_keystore_password_path, write_file_via_temporary, ZeroizeString};
use crate::{
default_keystore_password_path, read_password_string, write_file_via_temporary, ZeroizeString,
};
use directory::ensure_dir_exists;
use eth2_keystore::Keystore;
use regex::Regex;
@@ -43,6 +45,18 @@ pub enum Error {
UnableToOpenKeystore(eth2_keystore::Error),
/// The validator directory could not be created.
UnableToCreateValidatorDir(PathBuf),
UnableToReadKeystorePassword(String),
KeystoreWithoutPassword,
}
/// Defines how a password for a validator keystore will be persisted.
pub enum PasswordStorage {
/// Store the password in the `validator_definitions.yml` file.
ValidatorDefinitions(ZeroizeString),
/// Store the password in a separate, dedicated file (likely in the "secrets" directory).
File(PathBuf),
/// Don't store the password at all.
None,
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Hash, Eq)]
@@ -92,6 +106,34 @@ impl SigningDefinition {
pub fn is_local_keystore(&self) -> bool {
matches!(self, SigningDefinition::LocalKeystore { .. })
}
pub fn voting_keystore_password(&self) -> Result<Option<ZeroizeString>, Error> {
match self {
SigningDefinition::LocalKeystore {
voting_keystore_password: Some(password),
..
} => Ok(Some(password.clone())),
SigningDefinition::LocalKeystore {
voting_keystore_password_path: Some(path),
..
} => read_password_string(path)
.map(Into::into)
.map(Option::Some)
.map_err(Error::UnableToReadKeystorePassword),
SigningDefinition::LocalKeystore { .. } => Err(Error::KeystoreWithoutPassword),
SigningDefinition::Web3Signer(_) => Ok(None),
}
}
pub fn voting_keystore_password_path(&self) -> Option<&PathBuf> {
match self {
SigningDefinition::LocalKeystore {
voting_keystore_password_path: Some(path),
..
} => Some(path),
_ => None,
}
}
}
/// A validator that may be initialized by this validator client.
@@ -129,7 +171,7 @@ impl ValidatorDefinition {
/// This function does not check the password against the keystore.
pub fn new_keystore_with_password<P: AsRef<Path>>(
voting_keystore_path: P,
voting_keystore_password: Option<ZeroizeString>,
voting_keystore_password_storage: PasswordStorage,
graffiti: Option<GraffitiString>,
suggested_fee_recipient: Option<Address>,
gas_limit: Option<u64>,
@@ -139,6 +181,12 @@ impl ValidatorDefinition {
let keystore =
Keystore::from_json_file(&voting_keystore_path).map_err(Error::UnableToOpenKeystore)?;
let voting_public_key = keystore.public_key().ok_or(Error::InvalidKeystorePubkey)?;
let (voting_keystore_password_path, voting_keystore_password) =
match voting_keystore_password_storage {
PasswordStorage::ValidatorDefinitions(password) => (None, Some(password)),
PasswordStorage::File(path) => (Some(path), None),
PasswordStorage::None => (None, None),
};
Ok(ValidatorDefinition {
enabled: true,
@@ -150,7 +198,7 @@ impl ValidatorDefinition {
builder_proposals,
signing_definition: SigningDefinition::LocalKeystore {
voting_keystore_path,
voting_keystore_password_path: None,
voting_keystore_password_path,
voting_keystore_password,
},
})
@@ -346,6 +394,13 @@ impl ValidatorDefinitions {
pub fn as_mut_slice(&mut self) -> &mut [ValidatorDefinition] {
self.0.as_mut_slice()
}
// Returns an iterator over all the `voting_keystore_password_paths` in self.
pub fn iter_voting_keystore_password_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.0
.iter()
.filter_map(|def| def.signing_definition.voting_keystore_password_path())
}
}
/// Perform an exhaustive tree search of `dir`, adding any discovered voting keystore paths to
@@ -490,6 +490,21 @@ impl ValidatorClientHttpClient {
.await
}
/// `DELETE eth/v1/keystores`
pub async fn delete_lighthouse_keystores(
&self,
req: &DeleteKeystoresRequest,
) -> Result<ExportKeystoresResponse, Error> {
let mut path = self.server.full.clone();
path.path_segments_mut()
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
.push("lighthouse")
.push("keystores");
self.delete_with_unsigned_response(path, req).await
}
fn make_keystores_url(&self) -> Result<Url, Error> {
let mut url = self.server.full.clone();
url.path_segments_mut()
+3 -2
View File
@@ -1,9 +1,10 @@
use account_utils::ZeroizeString;
use eth2_keystore::Keystore;
use serde::{Deserialize, Serialize};
use slashing_protection::interchange::Interchange;
use types::{Address, PublicKeyBytes};
pub use slashing_protection::interchange::Interchange;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct GetFeeRecipientResponse {
pub pubkey: PublicKeyBytes,
@@ -27,7 +28,7 @@ pub struct ListKeystoresResponse {
pub data: Vec<SingleKeystoreResponse>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub struct SingleKeystoreResponse {
pub validating_pubkey: PublicKeyBytes,
pub derivation_path: Option<String>,
+16
View File
@@ -152,3 +152,19 @@ pub struct UpdateGasLimitRequest {
pub struct VoluntaryExitQuery {
pub epoch: Option<Epoch>,
}
#[derive(Deserialize, Serialize)]
pub struct ExportKeystoresResponse {
pub data: Vec<SingleExportKeystoresResponse>,
#[serde(with = "serde_utils::json_str")]
pub slashing_protection: Interchange,
}
#[derive(Deserialize, Serialize)]
pub struct SingleExportKeystoresResponse {
pub status: Status<DeleteKeystoreStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validating_keystore: Option<KeystoreJsonStr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validating_keystore_password: Option<ZeroizeString>,
}
+1
View File
@@ -20,6 +20,7 @@ tree_hash = "0.5.0"
hex = "0.4.2"
derivative = "2.1.1"
lockfile = { path = "../lockfile" }
directory = { path = "../directory" }
[dev-dependencies]
tempfile = "3.1.0"
+21 -2
View File
@@ -1,6 +1,7 @@
use crate::{Error as DirError, ValidatorDir};
use bls::get_withdrawal_credentials;
use deposit_contract::{encode_eth1_tx_data, Error as DepositError};
use directory::ensure_dir_exists;
use eth2_keystore::{Error as KeystoreError, Keystore, KeystoreBuilder, PlainText};
use filesystem::create_with_600_perms;
use rand::{distributions::Alphanumeric, Rng};
@@ -41,6 +42,7 @@ pub enum Error {
#[cfg(feature = "insecure_keys")]
InsecureKeysError(String),
MissingPasswordDir,
UnableToCreatePasswordDir(String),
}
impl From<KeystoreError> for Error {
@@ -78,6 +80,13 @@ impl<'a> Builder<'a> {
self
}
/// Optionally supply a directory in which to store the passwords for the validator keystores.
/// If `None` is provided, do not store the password.
pub fn password_dir_opt(mut self, password_dir_opt: Option<PathBuf>) -> Self {
self.password_dir = password_dir_opt;
self
}
/// Build the `ValidatorDir` use the given `keystore` which can be unlocked with `password`.
///
/// The builder will not necessarily check that `password` can unlock `keystore`.
@@ -153,6 +162,10 @@ impl<'a> Builder<'a> {
create_dir_all(&dir).map_err(Error::UnableToCreateDir)?;
}
if let Some(password_dir) = &self.password_dir {
ensure_dir_exists(password_dir).map_err(Error::UnableToCreatePasswordDir)?;
}
// The withdrawal keystore must be initialized in order to store it or create an eth1
// deposit.
if (self.store_withdrawal_keystore || self.deposit_info.is_some())
@@ -234,7 +247,7 @@ impl<'a> Builder<'a> {
if self.store_withdrawal_keystore {
// Write the withdrawal password to file.
write_password_to_file(
password_dir.join(withdrawal_keypair.pk.as_hex_string()),
keystore_password_path(password_dir, &withdrawal_keystore),
withdrawal_password.as_bytes(),
)?;
@@ -250,7 +263,7 @@ impl<'a> Builder<'a> {
if let Some(password_dir) = self.password_dir.as_ref() {
// Write the voting password to file.
write_password_to_file(
password_dir.join(format!("0x{}", voting_keystore.pubkey())),
keystore_password_path(password_dir, &voting_keystore),
voting_password.as_bytes(),
)?;
}
@@ -262,6 +275,12 @@ impl<'a> Builder<'a> {
}
}
pub fn keystore_password_path<P: AsRef<Path>>(password_dir: P, keystore: &Keystore) -> PathBuf {
password_dir
.as_ref()
.join(format!("0x{}", keystore.pubkey()))
}
/// Writes a JSON keystore to file.
fn write_keystore_to_file(path: PathBuf, keystore: &Keystore) -> Result<(), Error> {
if path.exists() {
+2 -2
View File
@@ -15,6 +15,6 @@ pub use crate::validator_dir::{
ETH1_DEPOSIT_TX_HASH_FILE,
};
pub use builder::{
Builder, Error as BuilderError, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
WITHDRAWAL_KEYSTORE_FILE,
keystore_password_path, Builder, Error as BuilderError, ETH1_DEPOSIT_DATA_FILE,
VOTING_KEYSTORE_FILE, WITHDRAWAL_KEYSTORE_FILE,
};
+2 -4
View File
@@ -1,5 +1,5 @@
use crate::builder::{
ETH1_DEPOSIT_AMOUNT_FILE, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
keystore_password_path, ETH1_DEPOSIT_AMOUNT_FILE, ETH1_DEPOSIT_DATA_FILE, VOTING_KEYSTORE_FILE,
WITHDRAWAL_KEYSTORE_FILE,
};
use deposit_contract::decode_eth1_tx_data;
@@ -219,9 +219,7 @@ pub fn unlock_keypair<P: AsRef<Path>>(
)
.map_err(Error::UnableToReadKeystore)?;
let password_path = password_dir
.as_ref()
.join(format!("0x{}", keystore.pubkey()));
let password_path = keystore_password_path(password_dir, &keystore);
let password: PlainText = read(&password_path)
.map_err(|_| Error::UnableToReadPassword(password_path))?
.into();