From 6ea3bc5e5214c3c625ef60ca4b325073bd0cfa4d Mon Sep 17 00:00:00 2001 From: Paul Hauner Date: Fri, 2 Oct 2020 09:42:19 +0000 Subject: [PATCH] Implement VC API (#1657) ## Issue Addressed NA ## Proposed Changes - Implements a HTTP API for the validator client. - Creates EIP-2335 keystores with an empty `description` field, instead of a missing `description` field. Adds option to set name. - Be more graceful with setups without any validators (yet) - Remove an error log when there are no validators. - Create the `validator` dir if it doesn't exist. - Allow building a `ValidatorDir` without a withdrawal keystore (required for the API method where we only post a voting keystore). - Add optional `description` field to `validator_definitions.yml` ## TODO - [x] Signature header, as per https://github.com/sigp/lighthouse/issues/1269#issuecomment-649879855 - [x] Return validator descriptions - [x] Return deposit data - [x] Respect the mnemonic offset - [x] Check that mnemonic can derive returned keys - [x] Be strict about non-localhost - [x] Allow graceful start without any validators (+ create validator dir) - [x] Docs final pass - [x] Swap to EIP-2335 description field. - [x] Fix Zerioze TODO in VC api types. - [x] Zeroize secp256k1 key ## Endpoints - [x] `GET /lighthouse/version` - [x] `GET /lighthouse/health` - [x] `GET /lighthouse/validators` - [x] `POST /lighthouse/validators/hd` - [x] `POST /lighthouse/validators/keystore` - [x] `PATCH /lighthouse/validators/:validator_pubkey` - [ ] ~~`POST /lighthouse/validators/:validator_pubkey/exit/:epoch`~~ Future works ## Additional Info TBC --- Cargo.lock | 105 ++-- account_manager/src/validator/create.rs | 3 +- account_manager/src/validator/recover.rs | 3 +- beacon_node/http_api/src/lib.rs | 21 +- book/src/SUMMARY.md | 3 + book/src/api-vc-auth-header.md | 55 ++ book/src/api-vc-endpoints.md | 363 ++++++++++++ book/src/api-vc-sig-header.md | 108 ++++ book/src/api-vc.md | 37 +- common/account_utils/src/lib.rs | 22 +- .../src/validator_definitions.rs | 4 + common/eth2/Cargo.toml | 6 + common/eth2/src/lib.rs | 15 +- common/eth2/src/lighthouse_vc/http_client.rs | 331 +++++++++++ common/eth2/src/lighthouse_vc/mod.rs | 9 + common/eth2/src/lighthouse_vc/types.rs | 58 ++ common/validator_dir/src/builder.rs | 54 +- common/validator_dir/src/insecure_keys.rs | 3 +- common/validator_dir/src/validator_dir.rs | 5 + common/validator_dir/tests/tests.rs | 68 +-- common/warp_utils/Cargo.toml | 2 + common/warp_utils/src/lib.rs | 1 + common/warp_utils/src/reject.rs | 18 + common/warp_utils/src/task.rs | 21 + crypto/eth2_keystore/src/keystore.rs | 24 +- crypto/eth2_wallet/src/wallet.rs | 17 + lcli/src/insecure_validators.rs | 3 +- lighthouse/environment/Cargo.toml | 2 +- lighthouse/src/main.rs | 2 +- lighthouse/tests/account_manager.rs | 1 + testing/simulator/src/local_network.rs | 2 +- validator_client/Cargo.toml | 10 + validator_client/src/cli.rs | 53 +- validator_client/src/config.rs | 55 +- validator_client/src/fork_service.rs | 79 ++- validator_client/src/http_api/api_secret.rs | 184 ++++++ .../src/http_api/create_validator.rs | 151 +++++ validator_client/src/http_api/mod.rs | 488 ++++++++++++++++ validator_client/src/http_api/tests.rs | 527 ++++++++++++++++++ .../src/initialized_validators.rs | 38 ++ validator_client/src/lib.rs | 53 +- validator_client/src/notifier.rs | 7 +- validator_client/src/validator_store.rs | 43 +- 43 files changed, 2882 insertions(+), 172 deletions(-) create mode 100644 book/src/api-vc-auth-header.md create mode 100644 book/src/api-vc-endpoints.md create mode 100644 book/src/api-vc-sig-header.md create mode 100644 common/eth2/src/lighthouse_vc/http_client.rs create mode 100644 common/eth2/src/lighthouse_vc/mod.rs create mode 100644 common/eth2/src/lighthouse_vc/types.rs create mode 100644 common/warp_utils/src/task.rs create mode 100644 validator_client/src/http_api/api_secret.rs create mode 100644 validator_client/src/http_api/create_validator.rs create mode 100644 validator_client/src/http_api/mod.rs create mode 100644 validator_client/src/http_api/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 34cf85678..256d91740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,15 +720,13 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.18" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d021fddb7bd3e734370acfa4a83f34095571d8570c039f1420d77540f68d5772" +checksum = "942f72db697d8767c22d46a598e01f2d3b475501ea43d0db4f16d90259182d0b" dependencies = [ - "libc", "num-integer", "num-traits", "time 0.1.44", - "winapi 0.3.9", ] [[package]] @@ -790,7 +788,7 @@ dependencies = [ "sloggers", "slot_clock", "store", - "time 0.2.22", + "time 0.2.21", "timer", "tokio 0.2.22", "toml", @@ -1292,6 +1290,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dtoa" version = "0.4.6" @@ -1469,16 +1473,22 @@ dependencies = [ name = "eth2" version = "0.1.0" dependencies = [ + "account_utils", + "bytes 0.5.6", + "eth2_keystore", "eth2_libp2p", "hex 0.4.2", + "libsecp256k1", "procinfo", "proto_array", "psutil", "reqwest", + "ring", "serde", "serde_json", "serde_utils", "types", + "zeroize", ] [[package]] @@ -2157,9 +2167,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +checksum = "00d63df3d41950fb462ed38308eea019113ad1508da725bbedcd0fa5a85ef5f7" [[package]] name = "hashset_delay" @@ -2543,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55e2e4c765aa53a0424761bf9f41aa7a6ac1efa87238f59560640e27fca028f2" dependencies = [ "autocfg 1.0.1", - "hashbrown 0.9.1", + "hashbrown 0.9.0", ] [[package]] @@ -3641,9 +3651,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" [[package]] name = "openssl-src" -version = "111.11.0+1.1.1h" +version = "111.10.2+1.1.1g" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380fe324132bea01f45239fadfec9343adb044615f29930d039bec1ae7b9fa5b" +checksum = "a287fdb22e32b5b60624d4a5a7a02dbe82777f730ec0dbc42a0554326fef5a70" dependencies = [ "cc", ] @@ -3844,18 +3854,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "0.4.24" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f48fad7cfbff853437be7cf54d7b993af21f53be7f0988cbfe4a51535aa77205" +checksum = "ca4433fff2ae79342e497d9f8ee990d174071408f28f726d6d83af93e58e48aa" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "0.4.24" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24c6d293bdd3ca5a1697997854c6cf7855e43fb6a0ba1c47af57a5bcafd158ae" +checksum = "2c0e815c3ee9a031fdf5af21c10aa17c573c9c6a566328d99e3936c34e36461f" dependencies = [ "proc-macro2", "quote", @@ -3864,9 +3874,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.1.9" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe74897791e156a0cd8cce0db31b9b2198e67877316bf3086c3acd187f719f0" +checksum = "282adbf10f2698a7a77f8e983a74b2d18176c19a7fd32a45446139ae7b02b715" [[package]] name = "pin-utils" @@ -3956,9 +3966,9 @@ checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a" [[package]] name = "proc-macro2" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ef7cd2518ead700af67bf9d1a658d90b6037d77110fd9c0445429d0ba1c6c9" +checksum = "36e28516df94f3dd551a587da5357459d9b36d945a7c37c3557928c1c2ff2a2c" dependencies = [ "unicode-xid", ] @@ -4060,9 +4070,9 @@ checksum = "cb14183cc7f213ee2410067e1ceeadba2a7478a59432ff0747a335202798b1e2" [[package]] name = "psutil" -version = "3.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cdb732329774b8765346796abd1e896e9b3c86aae7f135bb1dda98c2c460f55" +checksum = "094d0f0f32f77f62cd7d137d9b9599ef257d5c1323b36b25746679de2806f547" dependencies = [ "cfg-if", "darwin-libproc", @@ -4073,7 +4083,7 @@ dependencies = [ "num_cpus", "once_cell", "platforms", - "thiserror", + "snafu", "unescape", ] @@ -5066,6 +5076,27 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbee7696b84bbf3d89a1c2eccff0850e3047ed46bfcd2e92c29a2d074d57e252" +[[package]] +name = "snafu" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c4e6046e4691afe918fd1b603fd6e515bcda5388a1092a9edbada307d159f09" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7073448732a89f2f3e6581989106067f403d378faeafb4a50812eb814170d3e5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "snap" version = "1.0.1" @@ -5297,9 +5328,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.42" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c51d92969d209b54a98397e1b91c8ae82d8c87a7bb87df0b29aa2ad81454228" +checksum = "6690e3e9f692504b941dc6c3b188fd28df054f7fb8469ab40680df52fdcc842b" dependencies = [ "proc-macro2", "quote", @@ -5441,9 +5472,9 @@ dependencies = [ [[package]] name = "time" -version = "0.2.22" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55b7151c9065e80917fbf285d9a5d1432f60db41d170ccafc749a136b41a93af" +checksum = "2c2e31fb28e2a9f01f5ed6901b066c1ba2333c04b64dc61254142bafcb3feb2c" dependencies = [ "const_fn", "libc", @@ -5456,9 +5487,9 @@ dependencies = [ [[package]] name = "time-macros" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" +checksum = "9ae9b6e9f095bc105e183e3cd493d72579be3181ad4004fceb01adbe9eecab2d" dependencies = [ "proc-macro-hack", "time-macros-impl", @@ -5891,21 +5922,20 @@ checksum = "e987b6bf443f4b5b3b6f38704195592cca41c5bb7aedd3c3693c7081f8289860" [[package]] name = "tracing" -version = "0.1.21" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0987850db3733619253fe60e17cb59b82d37c7e6c0236bb81e4d6b87c879f27" +checksum = "6d79ca061b032d6ce30c660fded31189ca0b9922bf483cd70759f13a2d86786c" dependencies = [ "cfg-if", "log 0.4.11", - "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" -version = "0.1.17" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50de3927f93d202783f4513cda820ab47ef17f624b03c096e86ef00c67e6b5f" +checksum = "5bcf46c1f1f06aeea2d6b81f3c863d0930a596c86ad1920d4e5bad6dd1d7119a" dependencies = [ "lazy_static", ] @@ -6236,13 +6266,19 @@ dependencies = [ "exit-future", "futures 0.3.5", "hex 0.4.2", + "hyper 0.13.8", "libc", + "libsecp256k1", + "lighthouse_version", "logging", "parking_lot 0.11.0", + "rand 0.7.3", "rayon", + "ring", "serde", "serde_derive", "serde_json", + "serde_utils", "serde_yaml", "slashing_protection", "slog", @@ -6250,10 +6286,13 @@ dependencies = [ "slog-term", "slot_clock", "tempdir", + "tempfile", "tokio 0.2.22", "tree_hash", "types", "validator_dir", + "warp", + "warp_utils", ] [[package]] @@ -6369,7 +6408,9 @@ dependencies = [ "beacon_chain", "eth2", "safe_arith", + "serde", "state_processing", + "tokio 0.2.22", "types", "warp", ] diff --git a/account_manager/src/validator/create.rs b/account_manager/src/validator/create.rs index 9c503ecc6..863cbf1c0 100644 --- a/account_manager/src/validator/create.rs +++ b/account_manager/src/validator/create.rs @@ -218,7 +218,8 @@ pub fn cli_run( ) })?; - ValidatorDirBuilder::new(validator_dir.clone(), secrets_dir.clone()) + ValidatorDirBuilder::new(validator_dir.clone()) + .password_dir(secrets_dir.clone()) .voting_keystore(keystores.voting, voting_password.as_bytes()) .withdrawal_keystore(keystores.withdrawal, withdrawal_password.as_bytes()) .create_eth1_tx_data(deposit_gwei, &spec) diff --git a/account_manager/src/validator/recover.rs b/account_manager/src/validator/recover.rs index e3844d500..ecea0efbd 100644 --- a/account_manager/src/validator/recover.rs +++ b/account_manager/src/validator/recover.rs @@ -124,7 +124,8 @@ pub fn cli_run(matches: &ArgMatches, validator_dir: PathBuf) -> Result<(), Strin let voting_pubkey = keystores.voting.pubkey().to_string(); - ValidatorDirBuilder::new(validator_dir.clone(), secrets_dir.clone()) + ValidatorDirBuilder::new(validator_dir.clone()) + .password_dir(secrets_dir.clone()) .voting_keystore(keystores.voting, voting_password.as_bytes()) .withdrawal_keystore(keystores.withdrawal, withdrawal_password.as_bytes()) .store_withdrawal_keystore(matches.is_present(STORE_WITHDRAW_FLAG)) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index b23937b5d..1b52cbd2c 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -42,6 +42,7 @@ use types::{ SignedBeaconBlock, SignedVoluntaryExit, Slot, YamlConfig, }; use warp::Filter; +use warp_utils::task::{blocking_json_task, blocking_task}; const API_PREFIX: &str = "eth"; const API_VERSION: &str = "v1"; @@ -1727,23 +1728,3 @@ fn publish_network_message( )) }) } - -/// Execute some task in a tokio "blocking thread". These threads are ideal for long-running -/// (blocking) tasks since they don't jam up the core executor. -async fn blocking_task(func: F) -> T -where - F: Fn() -> T, -{ - tokio::task::block_in_place(func) -} - -/// A convenience wrapper around `blocking_task` for use with `warp` JSON responses. -async fn blocking_json_task(func: F) -> Result -where - F: Fn() -> Result, - T: Serialize, -{ - blocking_task(func) - .await - .map(|resp| warp::reply::json(&resp)) -} diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index b570357b9..a13ced95a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -19,6 +19,9 @@ * [/lighthouse](./api-lighthouse.md) * [Validator Inclusion APIs](./validator-inclusion.md) * [Validator Client API](./api-vc.md) + * [Endpoints](./api-vc-endpoints.md) + * [Authorization Header](./api-vc-auth-header.md) + * [Signature Header](./api-vc-sig-header.md) * [Prometheus Metrics](./advanced_metrics.md) * [Advanced Usage](./advanced.md) * [Database Configuration](./advanced_database.md) diff --git a/book/src/api-vc-auth-header.md b/book/src/api-vc-auth-header.md new file mode 100644 index 000000000..dbd334c9c --- /dev/null +++ b/book/src/api-vc-auth-header.md @@ -0,0 +1,55 @@ +# Validator Client API: Authorization Header + +## Overview + +The validator client HTTP server requires that all requests have the following +HTTP header: + +- Name: `Authorization` +- Value: `Basic ` + +Where `` is a string that can be obtained from the validator client +host. Here is an example `Authorization` header: + +``` +Authorization Basic api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123 +``` + +## Obtaining the API token + +The API token can be obtained via two methods: + +### Method 1: Reading from a file + +The API token is stored as a file in the `validators` directory. For most users +this is `~/.lighthouse/{testnet}/validators/api-token.txt`. Here's an +example using the `cat` command to print the token to the terminal, but any +text editor will suffice: + +``` +$ cat api-token.txt +api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123 +``` + +### Method 2: Reading from logs + +When starting the validator client it will output a log message containing an +`api-token` field: + +``` +Sep 28 19:17:52.615 INFO HTTP API started api_token: api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123, listen_address: 127.0.0.1:5062 +``` + +## Example + +Here is an example `curl` command using the API token in the `Authorization` header: + +```bash +curl localhost:5062/lighthouse/version -H "Authorization: Basic api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123" +``` + +The server should respond with its version: + +```json +{"data":{"version":"Lighthouse/v0.2.11-fc0654fbe+/x86_64-linux"}} +``` diff --git a/book/src/api-vc-endpoints.md b/book/src/api-vc-endpoints.md new file mode 100644 index 000000000..78ea49356 --- /dev/null +++ b/book/src/api-vc-endpoints.md @@ -0,0 +1,363 @@ +# Validator Client API: Endpoints + +## Endpoints + +HTTP Path | Description | +| --- | -- | +[`GET /lighthouse/version`](#get-lighthouseversion) | Get the Lighthouse software version +[`GET /lighthouse/health`](#get-lighthousehealth) | Get information about the host machine +[`GET /lighthouse/spec`](#get-lighthousespec) | Get the Eth2 specification used by the validator +[`GET /lighthouse/validators`](#get-lighthousevalidators) | List all validators +[`GET /lighthouse/validators/:voting_pubkey`](#get-lighthousevalidatorsvoting_pubkey) | Get a specific validator +[`PATCH /lighthouse/validators/:voting_pubkey`](#patch-lighthousevalidatorsvoting_pubkey) | Update a specific validator +[`POST /lighthouse/validators`](#post-lighthousevalidators) | Create a new validator and mnemonic. +[`POST /lighthouse/validators/mnemonic`](#post-lighthousevalidatorsmnemonic) | Create a new validator from an existing mnemonic. + +## `GET /lighthouse/version` + +Returns the software version and `git` commit hash for the Lighthouse binary. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/version` +Method | GET +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +### Example Response Body + +```json +{ + "data": { + "version": "Lighthouse/v0.2.11-fc0654fbe+/x86_64-linux" + } +} +``` + +## `GET /lighthouse/health` + +Returns information regarding the health of the host machine. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/health` +Method | GET +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +*Note: this endpoint is presently only available on Linux.* + +### Example Response Body + +```json +{ + "data": { + "pid": 1476293, + "pid_num_threads": 19, + "pid_mem_resident_set_size": 4009984, + "pid_mem_virtual_memory_size": 1306775552, + "sys_virt_mem_total": 33596100608, + "sys_virt_mem_available": 23073017856, + "sys_virt_mem_used": 9346957312, + "sys_virt_mem_free": 22410510336, + "sys_virt_mem_percent": 31.322334, + "sys_loadavg_1": 0.98, + "sys_loadavg_5": 0.98, + "sys_loadavg_15": 1.01 + } +} +``` + +## `GET /lighthouse/spec` + +Returns the Eth2 specification loaded for this validator. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/spec` +Method | GET +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +### Example Response Body + +```json +{ + "data": { + "CONFIG_NAME": "mainnet", + "MAX_COMMITTEES_PER_SLOT": "64", + "TARGET_COMMITTEE_SIZE": "128", + "MIN_PER_EPOCH_CHURN_LIMIT": "4", + "CHURN_LIMIT_QUOTIENT": "65536", + "SHUFFLE_ROUND_COUNT": "90", + "MIN_GENESIS_ACTIVE_VALIDATOR_COUNT": "1024", + "MIN_GENESIS_TIME": "1601380800", + "GENESIS_DELAY": "172800", + "MIN_DEPOSIT_AMOUNT": "1000000000", + "MAX_EFFECTIVE_BALANCE": "32000000000", + "EJECTION_BALANCE": "16000000000", + "EFFECTIVE_BALANCE_INCREMENT": "1000000000", + "HYSTERESIS_QUOTIENT": "4", + "HYSTERESIS_DOWNWARD_MULTIPLIER": "1", + "HYSTERESIS_UPWARD_MULTIPLIER": "5", + "PROPORTIONAL_SLASHING_MULTIPLIER": "3", + "GENESIS_FORK_VERSION": "0x00000002", + "BLS_WITHDRAWAL_PREFIX": "0x00", + "SECONDS_PER_SLOT": "12", + "MIN_ATTESTATION_INCLUSION_DELAY": "1", + "MIN_SEED_LOOKAHEAD": "1", + "MAX_SEED_LOOKAHEAD": "4", + "MIN_EPOCHS_TO_INACTIVITY_PENALTY": "4", + "MIN_VALIDATOR_WITHDRAWABILITY_DELAY": "256", + "SHARD_COMMITTEE_PERIOD": "256", + "BASE_REWARD_FACTOR": "64", + "WHISTLEBLOWER_REWARD_QUOTIENT": "512", + "PROPOSER_REWARD_QUOTIENT": "8", + "INACTIVITY_PENALTY_QUOTIENT": "16777216", + "MIN_SLASHING_PENALTY_QUOTIENT": "32", + "SAFE_SLOTS_TO_UPDATE_JUSTIFIED": "8", + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "DOMAIN_RANDAO": "0x02000000", + "DOMAIN_DEPOSIT": "0x03000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "MAX_VALIDATORS_PER_COMMITTEE": "2048", + "SLOTS_PER_EPOCH": "32", + "EPOCHS_PER_ETH1_VOTING_PERIOD": "32", + "SLOTS_PER_HISTORICAL_ROOT": "8192", + "EPOCHS_PER_HISTORICAL_VECTOR": "65536", + "EPOCHS_PER_SLASHINGS_VECTOR": "8192", + "HISTORICAL_ROOTS_LIMIT": "16777216", + "VALIDATOR_REGISTRY_LIMIT": "1099511627776", + "MAX_PROPOSER_SLASHINGS": "16", + "MAX_ATTESTER_SLASHINGS": "2", + "MAX_ATTESTATIONS": "128", + "MAX_DEPOSITS": "16", + "MAX_VOLUNTARY_EXITS": "16", + "ETH1_FOLLOW_DISTANCE": "1024", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "RANDOM_SUBNETS_PER_VALIDATOR": "1", + "EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION": "256", + "SECONDS_PER_ETH1_BLOCK": "14", + "DEPOSIT_CONTRACT_ADDRESS": "0x48b597f4b53c21b48ad95c7256b49d1779bd5890" + } +} +``` + +## `GET /lighthouse/validators` + +Lists all validators managed by this validator client. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/validators` +Method | GET +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +### Example Response Body + +```json +{ + "data": [ + { + "enabled": true, + "voting_pubkey": "0xb0148e6348264131bf47bcd1829590e870c836dc893050fd0dadc7a28949f9d0a72f2805d027521b45441101f0cc1cde" + }, + { + "enabled": true, + "voting_pubkey": "0xb0441246ed813af54c0a11efd53019f63dd454a1fa2a9939ce3c228419fbe113fb02b443ceeb38736ef97877eb88d43a" + }, + { + "enabled": true, + "voting_pubkey": "0xad77e388d745f24e13890353031dd8137432ee4225752642aad0a2ab003c86620357d91973b6675932ff51f817088f38" + } + ] +} +``` + +## `GET /lighthouse/validators/:voting_pubkey` + +Get a validator by their `voting_pubkey`. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/validators/:voting_pubkey` +Method | GET +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200, 400 + +### Example Path + +``` +localhost:5062/lighthouse/validators/0xb0148e6348264131bf47bcd1829590e870c836dc893050fd0dadc7a28949f9d0a72f2805d027521b45441101f0cc1cde +``` + +### Example Response Body + +```json +{ + "data": { + "enabled": true, + "voting_pubkey": "0xb0148e6348264131bf47bcd1829590e870c836dc893050fd0dadc7a28949f9d0a72f2805d027521b45441101f0cc1cde" + } +} +``` + +## `PATCH /lighthouse/validators/:voting_pubkey` + +Update some values for the validator with `voting_pubkey`. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/validators/:voting_pubkey` +Method | PATCH +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200, 400 + +### Example Path + +``` +localhost:5062/lighthouse/validators/0xb0148e6348264131bf47bcd1829590e870c836dc893050fd0dadc7a28949f9d0a72f2805d027521b45441101f0cc1cde +``` + +### Example Request Body + +```json +{ + "enabled": false +} +``` + +### Example Response Body + +```json +null +``` + +## `POST /lighthouse/validators/` + +Create any number of new validators, all of which will share a common mnemonic +generated by the server. + +A BIP-39 mnemonic will be randomly generated and returned with the response. +This mnemonic can be used to recover all keys returned in the response. +Validators are generated from the mnemonic according to +[EIP-2334](https://eips.ethereum.org/EIPS/eip-2334), starting at index `0`. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/validators` +Method | POST +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +### Example Request Body + +```json +[ + { + "enable": true, + "description": "validator_one", + "deposit_gwei": "32000000000" + }, + { + "enable": false, + "description": "validator two", + "deposit_gwei": "34000000000" + } +] +``` + +### Example Response Body + +```json +{ + "data": { + "mnemonic": "marine orchard scout label trim only narrow taste art belt betray soda deal diagram glare hero scare shadow ramp blur junior behave resource tourist", + "validators": [ + { + "enabled": true, + "description": "validator_one", + "voting_pubkey": "0x8ffbc881fb60841a4546b4b385ec5e9b5090fd1c4395e568d98b74b94b41a912c6101113da39d43c101369eeb9b48e50", + "eth1_deposit_tx_data": "0x22895118000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001206c68675776d418bfd63468789e7c68a6788c4dd45a3a911fe3d642668220bbf200000000000000000000000000000000000000000000000000000000000000308ffbc881fb60841a4546b4b385ec5e9b5090fd1c4395e568d98b74b94b41a912c6101113da39d43c101369eeb9b48e5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000cf8b3abbf0ecd91f3b0affcc3a11e9c5f8066efb8982d354ee9a812219b17000000000000000000000000000000000000000000000000000000000000000608fbe2cc0e17a98d4a58bd7a65f0475a58850d3c048da7b718f8809d8943fee1dbd5677c04b5fa08a9c44d271d009edcd15caa56387dc217159b300aad66c2cf8040696d383d0bff37b2892a7fe9ba78b2220158f3dc1b9cd6357bdcaee3eb9f2", + "deposit_gwei": "32000000000" + }, + { + "enabled": false, + "description": "validator two", + "voting_pubkey": "0xa9fadd620dc68e9fe0d6e1a69f6c54a0271ad65ab5a509e645e45c6e60ff8f4fc538f301781193a08b55821444801502", + "eth1_deposit_tx_data": "0x22895118000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120b1911954c1b8d23233e0e2bf8c4878c8f56d25a4f790ec09a94520ec88af30490000000000000000000000000000000000000000000000000000000000000030a9fadd620dc68e9fe0d6e1a69f6c54a0271ad65ab5a509e645e45c6e60ff8f4fc538f301781193a08b5582144480150200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000a96df8b95c3ba749265e48a101f2ed974fffd7487487ed55f8dded99b617ad000000000000000000000000000000000000000000000000000000000000006090421299179824950e2f5a592ab1fdefe5349faea1e8126146a006b64777b74cce3cfc5b39d35b370e8f844e99c2dc1b19a1ebd38c7605f28e9c4540aea48f0bc48e853ae5f477fa81a9fc599d1732968c772730e1e47aaf5c5117bd045b788e", + "deposit_gwei": "34000000000" + } + ] + } +} +``` + +## `POST /lighthouse/validators/mnemonic` + +Create any number of new validators, all of which will share a common mnemonic. + +The supplied BIP-39 mnemonic will be used to generate the validator keys +according to [EIP-2334](https://eips.ethereum.org/EIPS/eip-2334), starting at +the supplied `key_derivation_path_offset`. For example, if +`key_derivation_path_offset = 42`, then the first validator voting key will be +generated with the path `m/12381/3600/i/42`. + +### HTTP Specification + +| Property | Specification | +| --- |--- | +Path | `/lighthouse/validators/mnemonic` +Method | POST +Required Headers | [`Authorization`](./api-vc-auth-header.md) +Typical Responses | 200 + +### Example Request Body + +```json +{ + "mnemonic": "theme onion deal plastic claim silver fancy youth lock ordinary hotel elegant balance ridge web skill burger survey demand distance legal fish salad cloth", + "key_derivation_path_offset": 0, + "validators": [ + { + "enable": true, + "description": "validator_one", + "deposit_gwei": "32000000000" + } + ] +} +``` + +### Example Response Body + +```json +{ + "data": [ + { + "enabled": true, + "description": "validator_one", + "voting_pubkey": "0xa062f95fee747144d5e511940624bc6546509eeaeae9383257a9c43e7ddc58c17c2bab4ae62053122184c381b90db380", + "eth1_deposit_tx_data": "0x22895118000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120a57324d95ae9c7abfb5cc9bd4db253ed0605dc8a19f84810bcf3f3874d0e703a0000000000000000000000000000000000000000000000000000000000000030a062f95fee747144d5e511940624bc6546509eeaeae9383257a9c43e7ddc58c17c2bab4ae62053122184c381b90db3800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200046e4199f18102b5d4e8842d0eeafaa1268ee2c21340c63f9c2cd5b03ff19320000000000000000000000000000000000000000000000000000000000000060b2a897b4ba4f3910e9090abc4c22f81f13e8923ea61c0043506950b6ae174aa643540554037b465670d28fa7b7d716a301e9b172297122acc56be1131621c072f7c0a73ea7b8c5a90ecd5da06d79d90afaea17cdeeef8ed323912c70ad62c04b", + "deposit_gwei": "32000000000" + } + ] +} +``` diff --git a/book/src/api-vc-sig-header.md b/book/src/api-vc-sig-header.md new file mode 100644 index 000000000..a1b9b104f --- /dev/null +++ b/book/src/api-vc-sig-header.md @@ -0,0 +1,108 @@ +# Validator Client API: Signature Header + +## Overview + +The validator client HTTP server adds the following header to all responses: + +- Name: `Signature` +- Value: a secp256k1 signature across the SHA256 of the response body. + +Example `Signature` header: + +``` +Signature: 0x304402205b114366444112580bf455d919401e9c869f5af067cd496016ab70d428b5a99d0220067aede1eb5819eecfd5dd7a2b57c5ac2b98f25a7be214b05684b04523aef873 +``` + +## Verifying the Signature + +Below is a browser-ready example of signature verification. + +### HTML + +```html + + +``` + +### Javascript + +```javascript +// Helper function to turn a hex-string into bytes. +function hexStringToByte(str) { + if (!str) { + return new Uint8Array(); + } + + var a = []; + for (var i = 0, len = str.length; i < len; i+=2) { + a.push(parseInt(str.substr(i,2),16)); + } + + return new Uint8Array(a); +} + +// This example uses the secp256k1 curve from the "elliptic" library: +// +// https://github.com/indutny/elliptic +var ec = new elliptic.ec('secp256k1'); + +// The public key is contained in the API token: +// +// Authorization: Basic api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123 +var pk_bytes = hexStringToByte('03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123'); + +// The signature is in the `Signature` header of the response: +// +// Signature: 0x304402205b114366444112580bf455d919401e9c869f5af067cd496016ab70d428b5a99d0220067aede1eb5819eecfd5dd7a2b57c5ac2b98f25a7be214b05684b04523aef873 +var sig_bytes = hexStringToByte('304402205b114366444112580bf455d919401e9c869f5af067cd496016ab70d428b5a99d0220067aede1eb5819eecfd5dd7a2b57c5ac2b98f25a7be214b05684b04523aef873'); + +// The HTTP response body. +var response_body = "{\"data\":{\"version\":\"Lighthouse/v0.2.11-fc0654fbe+/x86_64-linux\"}}"; + +// The HTTP response body is hashed (SHA256) to determine the 32-byte message. +let hash = sha256.create(); +hash.update(response_body); +let message = hash.array(); + +// The 32-byte message hash, the signature and the public key are verified. +if (ec.verify(message, sig_bytes, pk_bytes)) { + console.log("The signature is valid") +} else { + console.log("The signature is invalid") +} +``` + +*This example is also available as a [JSFiddle](https://jsfiddle.net/wnqd74Lz/).* + +## Example + +The previous Javascript example was written using the output from the following +`curl` command: + +```bash +curl -v localhost:5062/lighthouse/version -H "Authorization: Basic api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123" +``` + +``` +* Trying ::1:5062... +* connect to ::1 port 5062 failed: Connection refused +* Trying 127.0.0.1:5062... +* Connected to localhost (127.0.0.1) port 5062 (#0) +> GET /lighthouse/version HTTP/1.1 +> Host: localhost:5062 +> User-Agent: curl/7.72.0 +> Accept: */* +> Authorization: Basic api-token-0x03eace4c98e8f77477bb99efb74f9af10d800bd3318f92c33b719a4644254d4123 +> +* Mark bundle as not supporting multiuse +< HTTP/1.1 200 OK +< content-type: application/json +< signature: 0x304402205b114366444112580bf455d919401e9c869f5af067cd496016ab70d428b5a99d0220067aede1eb5819eecfd5dd7a2b57c5ac2b98f25a7be214b05684b04523aef873 +< server: Lighthouse/v0.2.11-fc0654fbe+/x86_64-linux +< access-control-allow-origin: +< content-length: 65 +< date: Tue, 29 Sep 2020 04:23:46 GMT +< +* Connection #0 to host localhost left intact +{"data":{"version":"Lighthouse/v0.2.11-fc0654fbe+/x86_64-linux"}} +``` diff --git a/book/src/api-vc.md b/book/src/api-vc.md index e120f69bf..0a8941eda 100644 --- a/book/src/api-vc.md +++ b/book/src/api-vc.md @@ -1,3 +1,38 @@ # Validator Client API -The validator client API is planned for release in late September 2020. +Lighthouse implements a HTTP/JSON API for the validator client. Since there is +no Eth2 standard validator client API, Lighthouse has defined its own. + +A full list of endpoints can be found in [Endpoints](./api-vc-endpoints.md). + +> Note: All requests to the HTTP server must supply an +> [`Authorization`](./api-vc-auth-header.md) header. All responses contain a +> [`Signature`](./api-vc-sig-header.md) header for optional verification. + +## Starting the server + +A Lighthouse validator client can be configured to expose a HTTP server by supplying the `--http` flag. The default listen address is `127.0.0.1:5062`. + +The following CLI flags control the HTTP server: + +- `--http`: enable the HTTP server (required even if the following flags are + provided). +- `--http-port`: specify the listen port of the server. +- `--http-allow-origin`: specify the value of the `Access-Control-Allow-Origin` + header. The default is to not supply a header. + +## Security + +The validator client HTTP is **not encrypted** (i.e., it is **not HTTPS**). For +this reason, it will only listen on `127.0.0.1`. + +It is unsafe to expose the validator client to the public Internet without +additional transport layer security (e.g., HTTPS via nginx, SSH tunnels, etc.). + +### CLI Example + +Start the validator client with the HTTP server listening on [http://localhost:5062](http://localhost:5062): + +```bash +lighthouse vc --http +``` diff --git a/common/account_utils/src/lib.rs b/common/account_utils/src/lib.rs index 77351a7b9..d74ed71ed 100644 --- a/common/account_utils/src/lib.rs +++ b/common/account_utils/src/lib.rs @@ -2,7 +2,10 @@ //! Lighthouse project. use eth2_keystore::Keystore; -use eth2_wallet::Wallet; +use eth2_wallet::{ + bip39::{Language, Mnemonic, MnemonicType}, + Wallet, +}; use rand::{distributions::Alphanumeric, Rng}; use serde_derive::{Deserialize, Serialize}; use std::fs::{self, File}; @@ -15,6 +18,7 @@ use zeroize::Zeroize; pub mod validator_definitions; pub use eth2_keystore; +pub use eth2_wallet; pub use eth2_wallet::PlainText; /// The minimum number of characters required for a wallet password. @@ -150,6 +154,16 @@ pub fn is_password_sufficiently_complex(password: &[u8]) -> Result<(), String> { } } +/// Returns a random 24-word english mnemonic. +pub fn random_mnemonic() -> Mnemonic { + Mnemonic::new(MnemonicType::Words24, Language::English) +} + +/// Attempts to parse a mnemonic phrase. +pub fn mnemonic_from_phrase(phrase: &str) -> Result { + Mnemonic::from_phrase(phrase, Language::English).map_err(|e| e.to_string()) +} + /// Provides a new-type wrapper around `String` that is zeroized on `Drop`. /// /// Useful for ensuring that password memory is zeroed-out on drop. @@ -164,6 +178,12 @@ impl From for ZeroizeString { } } +impl ZeroizeString { + pub fn as_str(&self) -> &str { + &self.0 + } +} + impl AsRef<[u8]> for ZeroizeString { fn as_ref(&self) -> &[u8] { self.0.as_bytes() diff --git a/common/account_utils/src/validator_definitions.rs b/common/account_utils/src/validator_definitions.rs index 733c771be..b69678628 100644 --- a/common/account_utils/src/validator_definitions.rs +++ b/common/account_utils/src/validator_definitions.rs @@ -63,6 +63,8 @@ pub enum SigningDefinition { pub struct ValidatorDefinition { pub enabled: bool, pub voting_public_key: PublicKey, + #[serde(default)] + pub description: String, #[serde(flatten)] pub signing_definition: SigningDefinition, } @@ -88,6 +90,7 @@ impl ValidatorDefinition { Ok(ValidatorDefinition { enabled: true, voting_public_key, + description: keystore.description().unwrap_or_else(|| "").to_string(), signing_definition: SigningDefinition::LocalKeystore { voting_keystore_path, voting_keystore_password_path: None, @@ -205,6 +208,7 @@ impl ValidatorDefinitions { Some(ValidatorDefinition { enabled: true, voting_public_key, + description: keystore.description().unwrap_or_else(|| "").to_string(), signing_definition: SigningDefinition::LocalKeystore { voting_keystore_path, voting_keystore_password_path, diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index f7ccfcf34..479630c9c 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -15,6 +15,12 @@ reqwest = { version = "0.10.8", features = ["json"] } eth2_libp2p = { path = "../../beacon_node/eth2_libp2p" } proto_array = { path = "../../consensus/proto_array", optional = true } serde_utils = { path = "../../consensus/serde_utils" } +zeroize = { version = "1.0.0", features = ["zeroize_derive"] } +eth2_keystore = { path = "../../crypto/eth2_keystore" } +libsecp256k1 = "0.3.5" +ring = "0.16.12" +bytes = "0.5.6" +account_utils = { path = "../../common/account_utils" } [target.'cfg(target_os = "linux")'.dependencies] psutil = { version = "3.1.0", optional = true } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index b0fbc2566..8e9a18b47 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -9,6 +9,7 @@ #[cfg(feature = "lighthouse")] pub mod lighthouse; +pub mod lighthouse_vc; pub mod types; use self::types::*; @@ -30,6 +31,14 @@ pub enum Error { StatusCode(StatusCode), /// The supplied URL is badly formatted. It should look something like `http://127.0.0.1:5052`. InvalidUrl(Url), + /// The supplied validator client secret is invalid. + InvalidSecret(String), + /// The server returned a response with an invalid signature. It may be an impostor. + InvalidSignatureHeader, + /// The server returned a response without a signature header. It may be an impostor. + MissingSignatureHeader, + /// The server returned an invalid JSON response. + InvalidJson(serde_json::Error), } impl Error { @@ -40,6 +49,10 @@ impl Error { Error::ServerMessage(msg) => StatusCode::try_from(msg.code).ok(), Error::StatusCode(status) => Some(*status), Error::InvalidUrl(_) => None, + Error::InvalidSecret(_) => None, + Error::InvalidSignatureHeader => None, + Error::MissingSignatureHeader => None, + Error::InvalidJson(_) => None, } } } @@ -531,7 +544,7 @@ impl BeaconNodeHttpClient { self.get(path).await } - /// `GET config/fork_schedule` + /// `GET config/spec` pub async fn get_config_spec(&self) -> Result, Error> { let mut path = self.eth_path()?; diff --git a/common/eth2/src/lighthouse_vc/http_client.rs b/common/eth2/src/lighthouse_vc/http_client.rs new file mode 100644 index 000000000..b08ceabb2 --- /dev/null +++ b/common/eth2/src/lighthouse_vc/http_client.rs @@ -0,0 +1,331 @@ +use super::{types::*, PK_LEN, SECRET_PREFIX}; +use crate::Error; +use account_utils::ZeroizeString; +use bytes::Bytes; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + IntoUrl, +}; +use ring::digest::{digest, SHA256}; +use secp256k1::{Message, PublicKey, Signature}; +use serde::{de::DeserializeOwned, Serialize}; + +pub use reqwest; +pub use reqwest::{Response, StatusCode, Url}; + +/// A wrapper around `reqwest::Client` which provides convenience methods for interfacing with a +/// Lighthouse Validator Client HTTP server (`validator_client/src/http_api`). +#[derive(Clone)] +pub struct ValidatorClientHttpClient { + client: reqwest::Client, + server: Url, + secret: ZeroizeString, + server_pubkey: PublicKey, +} + +/// Parse an API token and return a secp256k1 public key. +pub fn parse_pubkey(secret: &str) -> Result { + let secret = if !secret.starts_with(SECRET_PREFIX) { + return Err(Error::InvalidSecret(format!( + "secret does not start with {}", + SECRET_PREFIX + ))); + } else { + &secret[SECRET_PREFIX.len()..] + }; + + serde_utils::hex::decode(&secret) + .map_err(|e| Error::InvalidSecret(format!("invalid hex: {:?}", e))) + .and_then(|bytes| { + if bytes.len() != PK_LEN { + return Err(Error::InvalidSecret(format!( + "expected {} bytes not {}", + PK_LEN, + bytes.len() + ))); + } + + let mut arr = [0; PK_LEN]; + arr.copy_from_slice(&bytes); + PublicKey::parse_compressed(&arr) + .map_err(|e| Error::InvalidSecret(format!("invalid secp256k1 pubkey: {:?}", e))) + }) +} + +impl ValidatorClientHttpClient { + pub fn new(server: Url, secret: String) -> Result { + Ok(Self { + client: reqwest::Client::new(), + server, + server_pubkey: parse_pubkey(&secret)?, + secret: secret.into(), + }) + } + + pub fn from_components( + server: Url, + client: reqwest::Client, + secret: String, + ) -> Result { + Ok(Self { + client, + server, + server_pubkey: parse_pubkey(&secret)?, + secret: secret.into(), + }) + } + + async fn signed_body(&self, response: Response) -> Result { + let sig = response + .headers() + .get("Signature") + .ok_or_else(|| Error::MissingSignatureHeader)? + .to_str() + .map_err(|_| Error::InvalidSignatureHeader)? + .to_string(); + + let body = response.bytes().await.map_err(Error::Reqwest)?; + + let message = + Message::parse_slice(digest(&SHA256, &body).as_ref()).expect("sha256 is 32 bytes"); + + serde_utils::hex::decode(&sig) + .ok() + .and_then(|bytes| { + let sig = Signature::parse_der(&bytes).ok()?; + Some(secp256k1::verify(&message, &sig, &self.server_pubkey)) + }) + .filter(|is_valid| *is_valid) + .ok_or_else(|| Error::InvalidSignatureHeader)?; + + Ok(body) + } + + async fn signed_json(&self, response: Response) -> Result { + let body = self.signed_body(response).await?; + serde_json::from_slice(&body).map_err(Error::InvalidJson) + } + + fn headers(&self) -> Result { + let header_value = HeaderValue::from_str(&format!("Basic {}", self.secret.as_str())) + .map_err(|e| { + Error::InvalidSecret(format!("secret is invalid as a header value: {}", e)) + })?; + + let mut headers = HeaderMap::new(); + headers.insert("Authorization", header_value); + + Ok(headers) + } + + /// Perform a HTTP GET request. + async fn get(&self, url: U) -> Result { + let response = self + .client + .get(url) + .headers(self.headers()?) + .send() + .await + .map_err(Error::Reqwest)?; + let response = ok_or_error(response).await?; + self.signed_json(response).await + } + + /// Perform a HTTP GET request, returning `None` on a 404 error. + async fn get_opt(&self, url: U) -> Result, Error> { + let response = self + .client + .get(url) + .headers(self.headers()?) + .send() + .await + .map_err(Error::Reqwest)?; + match ok_or_error(response).await { + Ok(resp) => self.signed_json(resp).await.map(Option::Some), + Err(err) => { + if err.status() == Some(StatusCode::NOT_FOUND) { + Ok(None) + } else { + Err(err) + } + } + } + } + + /// Perform a HTTP POST request. + async fn post( + &self, + url: U, + body: &T, + ) -> Result { + let response = self + .client + .post(url) + .headers(self.headers()?) + .json(body) + .send() + .await + .map_err(Error::Reqwest)?; + let response = ok_or_error(response).await?; + self.signed_json(response).await + } + + /// Perform a HTTP PATCH request. + async fn patch(&self, url: U, body: &T) -> Result<(), Error> { + let response = self + .client + .patch(url) + .headers(self.headers()?) + .json(body) + .send() + .await + .map_err(Error::Reqwest)?; + let response = ok_or_error(response).await?; + self.signed_body(response).await?; + Ok(()) + } + + /// `GET lighthouse/version` + pub async fn get_lighthouse_version(&self) -> Result, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("version"); + + self.get(path).await + } + + /// `GET lighthouse/health` + pub async fn get_lighthouse_health(&self) -> Result, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("health"); + + self.get(path).await + } + + /// `GET lighthouse/spec` + pub async fn get_lighthouse_spec(&self) -> Result, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("spec"); + + self.get(path).await + } + + /// `GET lighthouse/validators` + pub async fn get_lighthouse_validators( + &self, + ) -> Result>, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators"); + + self.get(path).await + } + + /// `GET lighthouse/validators/{validator_pubkey}` + pub async fn get_lighthouse_validators_pubkey( + &self, + validator_pubkey: &PublicKeyBytes, + ) -> Result>, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators") + .push(&validator_pubkey.to_string()); + + self.get_opt(path).await + } + + /// `POST lighthouse/validators` + pub async fn post_lighthouse_validators( + &self, + validators: Vec, + ) -> Result, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators"); + + self.post(path, &validators).await + } + + /// `POST lighthouse/validators/mnemonic` + pub async fn post_lighthouse_validators_mnemonic( + &self, + request: &CreateValidatorsMnemonicRequest, + ) -> Result>, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators") + .push("mnemonic"); + + self.post(path, &request).await + } + + /// `POST lighthouse/validators/keystore` + pub async fn post_lighthouse_validators_keystore( + &self, + request: &KeystoreValidatorsPostRequest, + ) -> Result, Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators") + .push("keystore"); + + self.post(path, &request).await + } + + /// `PATCH lighthouse/validators/{validator_pubkey}` + pub async fn patch_lighthouse_validators( + &self, + voting_pubkey: &PublicKeyBytes, + enabled: bool, + ) -> Result<(), Error> { + let mut path = self.server.clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("lighthouse") + .push("validators") + .push(&voting_pubkey.to_string()); + + self.patch(path, &ValidatorPatchRequest { enabled }).await + } +} + +/// Returns `Ok(response)` if the response is a `200 OK` response. Otherwise, creates an +/// appropriate error message. +async fn ok_or_error(response: Response) -> Result { + let status = response.status(); + + if status == StatusCode::OK { + Ok(response) + } else if let Ok(message) = response.json().await { + Err(Error::ServerMessage(message)) + } else { + Err(Error::StatusCode(status)) + } +} diff --git a/common/eth2/src/lighthouse_vc/mod.rs b/common/eth2/src/lighthouse_vc/mod.rs new file mode 100644 index 000000000..b7de7c715 --- /dev/null +++ b/common/eth2/src/lighthouse_vc/mod.rs @@ -0,0 +1,9 @@ +pub mod http_client; +pub mod types; + +/// The number of bytes in the secp256k1 public key used as the authorization token for the VC API. +pub const PK_LEN: usize = 33; + +/// The prefix for the secp256k1 public key when it is used as the authorization token for the VC +/// API. +pub const SECRET_PREFIX: &str = "api-token-"; diff --git a/common/eth2/src/lighthouse_vc/types.rs b/common/eth2/src/lighthouse_vc/types.rs new file mode 100644 index 000000000..64674e6fc --- /dev/null +++ b/common/eth2/src/lighthouse_vc/types.rs @@ -0,0 +1,58 @@ +use account_utils::ZeroizeString; +use eth2_keystore::Keystore; +use serde::{Deserialize, Serialize}; + +pub use crate::lighthouse::Health; +pub use crate::types::{GenericResponse, VersionData}; +pub use types::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ValidatorData { + pub enabled: bool, + pub description: String, + pub voting_pubkey: PublicKeyBytes, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ValidatorRequest { + pub enable: bool, + pub description: String, + #[serde(with = "serde_utils::quoted_u64")] + pub deposit_gwei: u64, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct CreateValidatorsMnemonicRequest { + pub mnemonic: ZeroizeString, + #[serde(with = "serde_utils::quoted_u32")] + pub key_derivation_path_offset: u32, + pub validators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CreatedValidator { + pub enabled: bool, + pub description: String, + pub voting_pubkey: PublicKeyBytes, + pub eth1_deposit_tx_data: String, + #[serde(with = "serde_utils::quoted_u64")] + pub deposit_gwei: u64, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct PostValidatorsResponseData { + pub mnemonic: ZeroizeString, + pub validators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ValidatorPatchRequest { + pub enabled: bool, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct KeystoreValidatorsPostRequest { + pub password: ZeroizeString, + pub enable: bool, + pub keystore: Keystore, +} diff --git a/common/validator_dir/src/builder.rs b/common/validator_dir/src/builder.rs index 4cdb75a5f..ad1b01ee4 100644 --- a/common/validator_dir/src/builder.rs +++ b/common/validator_dir/src/builder.rs @@ -40,6 +40,7 @@ pub enum Error { UninitializedWithdrawalKeystore, #[cfg(feature = "insecure_keys")] InsecureKeysError(String), + MissingPasswordDir, } impl From for Error { @@ -51,7 +52,7 @@ impl From for Error { /// A builder for creating a `ValidatorDir`. pub struct Builder<'a> { base_validators_dir: PathBuf, - password_dir: PathBuf, + password_dir: Option, pub(crate) voting_keystore: Option<(Keystore, PlainText)>, pub(crate) withdrawal_keystore: Option<(Keystore, PlainText)>, store_withdrawal_keystore: bool, @@ -60,10 +61,10 @@ pub struct Builder<'a> { impl<'a> Builder<'a> { /// Instantiate a new builder. - pub fn new(base_validators_dir: PathBuf, password_dir: PathBuf) -> Self { + pub fn new(base_validators_dir: PathBuf) -> Self { Self { base_validators_dir, - password_dir, + password_dir: None, voting_keystore: None, withdrawal_keystore: None, store_withdrawal_keystore: true, @@ -71,6 +72,12 @@ impl<'a> Builder<'a> { } } + /// Supply a directory in which to store the passwords for the validator keystores. + pub fn password_dir>(mut self, password_dir: P) -> Self { + self.password_dir = Some(password_dir.into()); + 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`. @@ -215,26 +222,35 @@ impl<'a> Builder<'a> { } } - // Only the withdrawal keystore if explicitly required. - if self.store_withdrawal_keystore { - // Write the withdrawal password to file. - write_password_to_file( - self.password_dir - .join(withdrawal_keypair.pk.to_hex_string()), - withdrawal_password.as_bytes(), - )?; + if self.password_dir.is_none() && self.store_withdrawal_keystore { + return Err(Error::MissingPasswordDir); + } - // Write the withdrawal keystore to file. - write_keystore_to_file(dir.join(WITHDRAWAL_KEYSTORE_FILE), &withdrawal_keystore)?; + if let Some(password_dir) = self.password_dir.as_ref() { + // Only the withdrawal keystore if explicitly required. + if self.store_withdrawal_keystore { + // Write the withdrawal password to file. + write_password_to_file( + password_dir.join(withdrawal_keypair.pk.to_hex_string()), + withdrawal_password.as_bytes(), + )?; + + // Write the withdrawal keystore to file. + write_keystore_to_file( + dir.join(WITHDRAWAL_KEYSTORE_FILE), + &withdrawal_keystore, + )?; + } } } - // Write the voting password to file. - write_password_to_file( - self.password_dir - .join(format!("0x{}", voting_keystore.pubkey())), - voting_password.as_bytes(), - )?; + 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())), + voting_password.as_bytes(), + )?; + } // Write the voting keystore to file. write_keystore_to_file(dir.join(VOTING_KEYSTORE_FILE), &voting_keystore)?; diff --git a/common/validator_dir/src/insecure_keys.rs b/common/validator_dir/src/insecure_keys.rs index 65bf036d1..8043db749 100644 --- a/common/validator_dir/src/insecure_keys.rs +++ b/common/validator_dir/src/insecure_keys.rs @@ -73,7 +73,8 @@ pub fn build_deterministic_validator_dirs( indices: &[usize], ) -> Result<(), String> { for &i in indices { - Builder::new(validators_dir.clone(), password_dir.clone()) + Builder::new(validators_dir.clone()) + .password_dir(password_dir.clone()) .insecure_voting_keypair(i) .map_err(|e| format!("Unable to generate insecure keypair: {:?}", e))? .store_withdrawal_keystore(false) diff --git a/common/validator_dir/src/validator_dir.rs b/common/validator_dir/src/validator_dir.rs index 23cb3a8c1..109566f66 100644 --- a/common/validator_dir/src/validator_dir.rs +++ b/common/validator_dir/src/validator_dir.rs @@ -129,6 +129,11 @@ impl ValidatorDir { &self.dir } + /// Returns the path to the voting keystore JSON file. + pub fn voting_keystore_path(&self) -> PathBuf { + self.dir.join(VOTING_KEYSTORE_FILE) + } + /// Attempts to read the keystore in `self.dir` and decrypt the keypair using a password file /// in `password_dir`. /// diff --git a/common/validator_dir/tests/tests.rs b/common/validator_dir/tests/tests.rs index 6e9bdc2b9..fd1b79f14 100644 --- a/common/validator_dir/tests/tests.rs +++ b/common/validator_dir/tests/tests.rs @@ -78,13 +78,11 @@ impl Harness { * Build the `ValidatorDir`. */ - let builder = Builder::new( - self.validators_dir.path().into(), - self.password_dir.path().into(), - ) - // Note: setting the withdrawal keystore here ensure that it can get overriden by later - // calls to `random_withdrawal_keystore`. - .store_withdrawal_keystore(config.store_withdrawal_keystore); + let builder = Builder::new(self.validators_dir.path().into()) + .password_dir(self.password_dir.path()) + // Note: setting the withdrawal keystore here ensure that it can get replaced by + // further calls to `random_withdrawal_keystore`. + .store_withdrawal_keystore(config.store_withdrawal_keystore); let builder = if config.random_voting_keystore { builder.random_voting_keystore().unwrap() @@ -208,13 +206,11 @@ fn without_voting_keystore() { let harness = Harness::new(); assert!(matches!( - Builder::new( - harness.validators_dir.path().into(), - harness.password_dir.path().into(), - ) - .random_withdrawal_keystore() - .unwrap() - .build(), + Builder::new(harness.validators_dir.path().into(),) + .password_dir(harness.password_dir.path()) + .random_withdrawal_keystore() + .unwrap() + .build(), Err(BuilderError::UninitializedVotingKeystore) )) } @@ -225,26 +221,22 @@ fn without_withdrawal_keystore() { let spec = &MainnetEthSpec::default_spec(); // Should build without withdrawal keystore if not storing the it or creating eth1 data. - Builder::new( - harness.validators_dir.path().into(), - harness.password_dir.path().into(), - ) - .random_voting_keystore() - .unwrap() - .store_withdrawal_keystore(false) - .build() - .unwrap(); + Builder::new(harness.validators_dir.path().into()) + .password_dir(harness.password_dir.path()) + .random_voting_keystore() + .unwrap() + .store_withdrawal_keystore(false) + .build() + .unwrap(); assert!( matches!( - Builder::new( - harness.validators_dir.path().into(), - harness.password_dir.path().into(), - ) - .random_voting_keystore() - .unwrap() - .store_withdrawal_keystore(true) - .build(), + Builder::new(harness.validators_dir.path().into(),) + .password_dir(harness.password_dir.path()) + .random_voting_keystore() + .unwrap() + .store_withdrawal_keystore(true) + .build(), Err(BuilderError::UninitializedWithdrawalKeystore) ), "storing the keystore requires keystore" @@ -252,14 +244,12 @@ fn without_withdrawal_keystore() { assert!( matches!( - Builder::new( - harness.validators_dir.path().into(), - harness.password_dir.path().into(), - ) - .random_voting_keystore() - .unwrap() - .create_eth1_tx_data(42, spec) - .build(), + Builder::new(harness.validators_dir.path().into(),) + .password_dir(harness.password_dir.path()) + .random_voting_keystore() + .unwrap() + .create_eth1_tx_data(42, spec) + .build(), Err(BuilderError::UninitializedWithdrawalKeystore) ), "storing the keystore requires keystore" diff --git a/common/warp_utils/Cargo.toml b/common/warp_utils/Cargo.toml index 98ddab5d8..5d4a0fbbc 100644 --- a/common/warp_utils/Cargo.toml +++ b/common/warp_utils/Cargo.toml @@ -13,3 +13,5 @@ types = { path = "../../consensus/types" } beacon_chain = { path = "../../beacon_node/beacon_chain" } state_processing = { path = "../../consensus/state_processing" } safe_arith = { path = "../../consensus/safe_arith" } +serde = { version = "1.0.110", features = ["derive"] } +tokio = { version = "0.2.21", features = ["sync"] } diff --git a/common/warp_utils/src/lib.rs b/common/warp_utils/src/lib.rs index ec9cf3c34..ba02273e6 100644 --- a/common/warp_utils/src/lib.rs +++ b/common/warp_utils/src/lib.rs @@ -3,3 +3,4 @@ pub mod reject; pub mod reply; +pub mod task; diff --git a/common/warp_utils/src/reject.rs b/common/warp_utils/src/reject.rs index 1243d5f68..020fa19d8 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -101,6 +101,15 @@ pub fn not_synced(msg: String) -> warp::reject::Rejection { warp::reject::custom(NotSynced(msg)) } +#[derive(Debug)] +pub struct InvalidAuthorization(pub String); + +impl Reject for InvalidAuthorization {} + +pub fn invalid_auth(msg: String) -> warp::reject::Rejection { + warp::reject::custom(InvalidAuthorization(msg)) +} + /// This function receives a `Rejection` and tries to return a custom /// value, otherwise simply passes the rejection along. pub async fn handle_rejection(err: warp::Rejection) -> Result { @@ -150,6 +159,15 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result() { code = StatusCode::SERVICE_UNAVAILABLE; message = format!("SERVICE_UNAVAILABLE: beacon node is syncing: {}", e.0); + } else if let Some(e) = err.find::() { + code = StatusCode::FORBIDDEN; + message = format!("FORBIDDEN: Invalid auth token: {}", e.0); + } else if let Some(e) = err.find::() { + code = StatusCode::BAD_REQUEST; + message = format!("BAD_REQUEST: missing {} header", e.name()); + } else if let Some(e) = err.find::() { + code = StatusCode::BAD_REQUEST; + message = format!("BAD_REQUEST: invalid {} header", e.name()); } else if err.find::().is_some() { code = StatusCode::METHOD_NOT_ALLOWED; message = "METHOD_NOT_ALLOWED".to_string(); diff --git a/common/warp_utils/src/task.rs b/common/warp_utils/src/task.rs new file mode 100644 index 000000000..da4cf91be --- /dev/null +++ b/common/warp_utils/src/task.rs @@ -0,0 +1,21 @@ +use serde::Serialize; + +/// Execute some task in a tokio "blocking thread". These threads are ideal for long-running +/// (blocking) tasks since they don't jam up the core executor. +pub async fn blocking_task(func: F) -> T +where + F: Fn() -> T, +{ + tokio::task::block_in_place(func) +} + +/// A convenience wrapper around `blocking_task` for use with `warp` JSON responses. +pub async fn blocking_json_task(func: F) -> Result +where + F: Fn() -> Result, + T: Serialize, +{ + blocking_task(func) + .await + .map(|resp| warp::reply::json(&resp)) +} diff --git a/crypto/eth2_keystore/src/keystore.rs b/crypto/eth2_keystore/src/keystore.rs index 6e3128b00..c1997680d 100644 --- a/crypto/eth2_keystore/src/keystore.rs +++ b/crypto/eth2_keystore/src/keystore.rs @@ -81,6 +81,7 @@ pub struct KeystoreBuilder<'a> { cipher: Cipher, uuid: Uuid, path: String, + description: String, } impl<'a> KeystoreBuilder<'a> { @@ -105,10 +106,17 @@ impl<'a> KeystoreBuilder<'a> { cipher: Cipher::Aes128Ctr(Aes128Ctr { iv }), uuid: Uuid::new_v4(), path, + description: "".to_string(), }) } } + /// Build the keystore with a specific description instead of an empty string. + pub fn description(mut self, description: String) -> Self { + self.description = description; + self + } + /// Build the keystore using the supplied `kdf` instead of `crate::default_kdf`. pub fn kdf(mut self, kdf: Kdf) -> Self { self.kdf = kdf; @@ -124,6 +132,7 @@ impl<'a> KeystoreBuilder<'a> { self.cipher, self.uuid, self.path, + self.description, ) } } @@ -147,6 +156,7 @@ impl Keystore { cipher: Cipher, uuid: Uuid, path: String, + description: String, ) -> Result { let secret: ZeroizeHash = keypair.sk.serialize(); @@ -175,7 +185,7 @@ impl Keystore { path: Some(path), pubkey: keypair.pk.to_hex_string()[2..].to_string(), version: Version::four(), - description: None, + description: Some(description), name: None, }, }) @@ -228,6 +238,18 @@ impl Keystore { &self.json.pubkey } + /// Returns the description for the keystore, if the field is present. + pub fn description(&self) -> Option<&str> { + self.json.description.as_deref() + } + + /// Sets the description for the keystore. + /// + /// Note: this does not save the keystore to disk. + pub fn set_description(&mut self, description: String) { + self.json.description = Some(description) + } + /// Returns the pubkey for the keystore, parsed as a `PublicKey` if it parses. pub fn public_key(&self) -> Option { serde_json::from_str(&format!("\"0x{}\"", &self.json.pubkey)).ok() diff --git a/crypto/eth2_wallet/src/wallet.rs b/crypto/eth2_wallet/src/wallet.rs index 47b2d329d..e0d0d04f6 100644 --- a/crypto/eth2_wallet/src/wallet.rs +++ b/crypto/eth2_wallet/src/wallet.rs @@ -215,6 +215,23 @@ impl Wallet { self.json.nextaccount } + /// Sets the value of the JSON wallet `nextaccount` field. + /// + /// This will be the index of the next wallet generated with `Self::next_validator`. + /// + /// ## Errors + /// + /// Returns `Err(())` if `nextaccount` is less than `self.nextaccount()` without mutating + /// `self`. This is to protect against duplicate validator generation. + pub fn set_nextaccount(&mut self, nextaccount: u32) -> Result<(), ()> { + if nextaccount >= self.nextaccount() { + self.json.nextaccount = nextaccount; + Ok(()) + } else { + Err(()) + } + } + /// Returns the value of the JSON wallet `name` field. pub fn name(&self) -> &str { &self.json.name diff --git a/lcli/src/insecure_validators.rs b/lcli/src/insecure_validators.rs index c04f854a5..2a604cffe 100644 --- a/lcli/src/insecure_validators.rs +++ b/lcli/src/insecure_validators.rs @@ -21,7 +21,8 @@ pub fn run(matches: &ArgMatches) -> Result<(), String> { for i in 0..validator_count { println!("Validator {}/{}", i + 1, validator_count); - ValidatorBuilder::new(validators_dir.clone(), secrets_dir.clone()) + ValidatorBuilder::new(validators_dir.clone()) + .password_dir(secrets_dir.clone()) .store_withdrawal_keystore(false) .insecure_voting_keypair(i) .map_err(|e| format!("Unable to generate keys: {:?}", e))? diff --git a/lighthouse/environment/Cargo.toml b/lighthouse/environment/Cargo.toml index ee191f38d..0d622990f 100644 --- a/lighthouse/environment/Cargo.toml +++ b/lighthouse/environment/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Paul Hauner "] edition = "2018" [dependencies] -tokio = { version = "0.2.21", features = ["macros"] } +tokio = { version = "0.2.21", features = ["full"] } slog = { version = "2.5.2", features = ["max_level_trace"] } sloggers = "1.0.0" types = { "path" = "../../consensus/types" } diff --git a/lighthouse/src/main.rs b/lighthouse/src/main.rs index 9d13706a1..376381c27 100644 --- a/lighthouse/src/main.rs +++ b/lighthouse/src/main.rs @@ -283,7 +283,7 @@ fn run( let context = environment.core_context(); let log = context.log().clone(); let executor = context.executor.clone(); - let config = validator_client::Config::from_cli(&matches) + let config = validator_client::Config::from_cli(&matches, context.log()) .map_err(|e| format!("Unable to initialize validator config: {}", e))?; environment.runtime().spawn(async move { let run = async { diff --git a/lighthouse/tests/account_manager.rs b/lighthouse/tests/account_manager.rs index 3c963f5b1..4ce7bb8ac 100644 --- a/lighthouse/tests/account_manager.rs +++ b/lighthouse/tests/account_manager.rs @@ -481,6 +481,7 @@ fn validator_import_launchpad() { let expected_def = ValidatorDefinition { enabled: true, + description: "".into(), voting_public_key: keystore.public_key().unwrap(), signing_definition: SigningDefinition::LocalKeystore { voting_keystore_path, diff --git a/testing/simulator/src/local_network.rs b/testing/simulator/src/local_network.rs index 0dd9b3424..ed17f69ee 100644 --- a/testing/simulator/src/local_network.rs +++ b/testing/simulator/src/local_network.rs @@ -128,7 +128,7 @@ impl LocalNetwork { .expect("Must have http started") }; - validator_config.http_server = + validator_config.beacon_node = format!("http://{}:{}", socket_addr.ip(), socket_addr.port()); let validator_client = LocalValidatorClient::production_with_insecure_keypairs( context, diff --git a/validator_client/Cargo.toml b/validator_client/Cargo.toml index b69b31f57..2f1b753e1 100644 --- a/validator_client/Cargo.toml +++ b/validator_client/Cargo.toml @@ -10,6 +10,8 @@ path = "src/lib.rs" [dev-dependencies] tokio = { version = "0.2.21", features = ["time", "rt-threaded", "macros"] } +tempfile = "3.1.0" +deposit_contract = { path = "../common/deposit_contract" } [dependencies] eth2_ssz = "0.1.2" @@ -47,3 +49,11 @@ validator_dir = { path = "../common/validator_dir" } clap_utils = { path = "../common/clap_utils" } eth2_keystore = { path = "../crypto/eth2_keystore" } account_utils = { path = "../common/account_utils" } +lighthouse_version = { path = "../common/lighthouse_version" } +warp_utils = { path = "../common/warp_utils" } +warp = "0.2.5" +hyper = "0.13.5" +serde_utils = { path = "../consensus/serde_utils" } +libsecp256k1 = "0.3.5" +ring = "0.16.12" +rand = "0.7.3" diff --git a/validator_client/src/cli.rs b/validator_client/src/cli.rs index 9ad0c3faa..0651bf536 100644 --- a/validator_client/src/cli.rs +++ b/validator_client/src/cli.rs @@ -1,4 +1,4 @@ -use crate::config::DEFAULT_HTTP_SERVER; +use crate::config::DEFAULT_BEACON_NODE; use clap::{App, Arg}; pub fn cli_app<'a, 'b>() -> App<'a, 'b> { @@ -8,13 +8,22 @@ pub fn cli_app<'a, 'b>() -> App<'a, 'b> { "When connected to a beacon node, performs the duties of a staked \ validator (e.g., proposing blocks and attestations).", ) + .arg( + Arg::with_name("beacon-node") + .long("beacon-node") + .value_name("NETWORK_ADDRESS") + .help("Address to a beacon node HTTP API") + .default_value(&DEFAULT_BEACON_NODE) + .takes_value(true), + ) + // This argument is deprecated, use `--beacon-node` instead. .arg( Arg::with_name("server") .long("server") .value_name("NETWORK_ADDRESS") - .help("Address to connect to BeaconNode.") - .default_value(&DEFAULT_HTTP_SERVER) - .takes_value(true), + .help("Deprecated. Use --beacon-node.") + .takes_value(true) + .conflicts_with("beacon-node"), ) .arg( Arg::with_name("validators-dir") @@ -97,4 +106,40 @@ pub fn cli_app<'a, 'b>() -> App<'a, 'b> { .value_name("GRAFFITI") .takes_value(true) ) + /* REST API related arguments */ + .arg( + Arg::with_name("http") + .long("http") + .help("Enable the RESTful HTTP API server. Disabled by default.") + .takes_value(false), + ) + /* + * Note: there is purposefully no `--http-address` flag provided. + * + * The HTTP server is **not** encrypted (i.e., not HTTPS) and therefore it is unsafe to + * publish on a public network. + * + * We restrict the user to `127.0.0.1` and they must provide some other transport-layer + * encryption (e.g., SSH tunnels). + */ + .arg( + Arg::with_name("http-port") + .long("http-port") + .value_name("PORT") + .help("Set the listen TCP port for the RESTful HTTP API server. This server does **not** \ + provide encryption and is completely unsuitable to expose to a public network. \ + We do not provide a --http-address flag and restrict the user to listening on \ + 127.0.0.1. For access via the Internet, apply a transport-layer security like \ + a HTTPS reverse-proxy or SSH tunnelling.") + .default_value("5062") + .takes_value(true), + ) + .arg( + Arg::with_name("http-allow-origin") + .long("http-allow-origin") + .value_name("ORIGIN") + .help("Set the value of the Access-Control-Allow-Origin response HTTP header. Use * to allow any origin (not recommended in production)") + .default_value("") + .takes_value(true), + ) } diff --git a/validator_client/src/config.rs b/validator_client/src/config.rs index 1551f1aee..d51f44e44 100644 --- a/validator_client/src/config.rs +++ b/validator_client/src/config.rs @@ -1,3 +1,4 @@ +use crate::http_api; use clap::ArgMatches; use clap_utils::{parse_optional, parse_required}; use directory::{ @@ -6,10 +7,12 @@ use directory::{ }; use eth2::types::Graffiti; use serde_derive::{Deserialize, Serialize}; +use slog::{warn, Logger}; +use std::fs; use std::path::PathBuf; use types::GRAFFITI_BYTES_LEN; -pub const DEFAULT_HTTP_SERVER: &str = "http://localhost:5052/"; +pub const DEFAULT_BEACON_NODE: &str = "http://localhost:5052/"; /// Stores the core configuration for this validator instance. #[derive(Clone, Serialize, Deserialize)] @@ -21,7 +24,7 @@ pub struct Config { /// The http endpoint of the beacon node API. /// /// Should be similar to `http://localhost:8080` - pub http_server: String, + pub beacon_node: String, /// If true, the validator client will still poll for duties and produce blocks even if the /// beacon node is not synced at startup. pub allow_unsynced_beacon_node: bool, @@ -33,6 +36,8 @@ pub struct Config { pub strict_slashing_protection: bool, /// Graffiti to be inserted everytime we create a block. pub graffiti: Option, + /// Configuration for the HTTP REST API. + pub http_api: http_api::Config, } impl Default for Config { @@ -49,12 +54,13 @@ impl Default for Config { Self { validator_dir, secrets_dir, - http_server: DEFAULT_HTTP_SERVER.to_string(), + beacon_node: DEFAULT_BEACON_NODE.to_string(), allow_unsynced_beacon_node: false, delete_lockfiles: false, disable_auto_discover: false, strict_slashing_protection: false, graffiti: None, + http_api: <_>::default(), } } } @@ -62,7 +68,7 @@ impl Default for Config { impl Config { /// Returns a `Default` implementation of `Self` with some parameters modified by the supplied /// `cli_args`. - pub fn from_cli(cli_args: &ArgMatches) -> Result { + pub fn from_cli(cli_args: &ArgMatches, log: &Logger) -> Result { let mut config = Config::default(); let default_root_dir = dirs::home_dir() @@ -95,14 +101,22 @@ impl Config { }); if !config.validator_dir.exists() { - return Err(format!( - "The directory for validator data does not exist: {:?}", - config.validator_dir - )); + fs::create_dir_all(&config.validator_dir) + .map_err(|e| format!("Failed to create {:?}: {:?}", config.validator_dir, e))?; } + if let Some(beacon_node) = parse_optional(cli_args, "beacon-node")? { + config.beacon_node = beacon_node; + } + + // To be deprecated. if let Some(server) = parse_optional(cli_args, "server")? { - config.http_server = server; + warn!( + log, + "The --server flag is deprecated"; + "msg" => "please use --beacon-node instead" + ); + config.beacon_node = server; } config.allow_unsynced_beacon_node = cli_args.is_present("allow-unsynced"); @@ -129,6 +143,29 @@ impl Config { } } + /* + * Http API server + */ + + if cli_args.is_present("http") { + config.http_api.enabled = true; + } + + if let Some(port) = cli_args.value_of("http-port") { + config.http_api.listen_port = port + .parse::() + .map_err(|_| "http-port is not a valid u16.")?; + } + + if let Some(allow_origin) = cli_args.value_of("http-allow-origin") { + // Pre-validate the config value to give feedback to the user on node startup, instead of + // as late as when the first API response is produced. + hyper::header::HeaderValue::from_str(allow_origin) + .map_err(|_| "Invalid allow-origin value")?; + + config.http_api.allow_origin = Some(allow_origin.to_string()); + } + Ok(config) } } diff --git a/validator_client/src/fork_service.rs b/validator_client/src/fork_service.rs index e38a4cf3c..58665ee01 100644 --- a/validator_client/src/fork_service.rs +++ b/validator_client/src/fork_service.rs @@ -2,31 +2,32 @@ use environment::RuntimeContext; use eth2::{types::StateId, BeaconNodeHttpClient}; use futures::StreamExt; use parking_lot::RwLock; +use slog::Logger; use slog::{debug, trace}; use slot_clock::SlotClock; use std::ops::Deref; use std::sync::Arc; use tokio::time::{interval_at, Duration, Instant}; -use types::{ChainSpec, EthSpec, Fork}; +use types::{EthSpec, Fork}; /// Delay this period of time after the slot starts. This allows the node to process the new slot. const TIME_DELAY_FROM_SLOT: Duration = Duration::from_millis(80); /// Builds a `ForkService`. -pub struct ForkServiceBuilder { +pub struct ForkServiceBuilder { fork: Option, slot_clock: Option, beacon_node: Option, - context: Option>, + log: Option, } -impl ForkServiceBuilder { +impl ForkServiceBuilder { pub fn new() -> Self { Self { fork: None, slot_clock: None, beacon_node: None, - context: None, + log: None, } } @@ -40,12 +41,12 @@ impl ForkServiceBuilder { self } - pub fn runtime_context(mut self, context: RuntimeContext) -> Self { - self.context = Some(context); + pub fn log(mut self, log: Logger) -> Self { + self.log = Some(log); self } - pub fn build(self) -> Result, String> { + pub fn build(self) -> Result, String> { Ok(ForkService { inner: Arc::new(Inner { fork: RwLock::new(self.fork), @@ -55,28 +56,48 @@ impl ForkServiceBuilder { beacon_node: self .beacon_node .ok_or_else(|| "Cannot build ForkService without beacon_node")?, - context: self - .context - .ok_or_else(|| "Cannot build ForkService without runtime_context")?, + log: self + .log + .ok_or_else(|| "Cannot build ForkService without logger")? + .clone(), }), }) } } +#[cfg(test)] +#[allow(dead_code)] +impl ForkServiceBuilder { + pub fn testing_only(log: Logger) -> Self { + Self { + fork: Some(types::Fork::default()), + slot_clock: Some(slot_clock::TestingSlotClock::new( + types::Slot::new(0), + std::time::Duration::from_secs(42), + std::time::Duration::from_secs(42), + )), + beacon_node: Some(eth2::BeaconNodeHttpClient::new( + eth2::Url::parse("http://127.0.0.1").unwrap(), + )), + log: Some(log), + } + } +} + /// Helper to minimise `Arc` usage. -pub struct Inner { +pub struct Inner { fork: RwLock>, beacon_node: BeaconNodeHttpClient, - context: RuntimeContext, + log: Logger, slot_clock: T, } /// Attempts to download the `Fork` struct from the beacon node at the start of each epoch. -pub struct ForkService { - inner: Arc>, +pub struct ForkService { + inner: Arc>, } -impl Clone for ForkService { +impl Clone for ForkService { fn clone(&self) -> Self { Self { inner: self.inner.clone(), @@ -84,22 +105,27 @@ impl Clone for ForkService { } } -impl Deref for ForkService { - type Target = Inner; +impl Deref for ForkService { + type Target = Inner; fn deref(&self) -> &Self::Target { self.inner.deref() } } -impl ForkService { +impl ForkService { /// Returns the last fork downloaded from the beacon node, if any. pub fn fork(&self) -> Option { *self.fork.read() } /// Starts the service that periodically polls for the `Fork`. - pub fn start_update_service(self, spec: &ChainSpec) -> Result<(), String> { + pub fn start_update_service( + self, + context: &RuntimeContext, + ) -> Result<(), String> { + let spec = &context.eth2_config.spec; + let duration_to_next_epoch = self .slot_clock .duration_to_next_epoch(E::slots_per_epoch()) @@ -115,13 +141,12 @@ impl ForkService { }; // Run an immediate update before starting the updater service. - self.inner - .context + context .executor .runtime_handle() .spawn(self.clone().do_update()); - let executor = self.inner.context.executor.clone(); + let executor = context.executor.clone(); let interval_fut = async move { while interval.next().await.is_some() { @@ -136,8 +161,6 @@ impl ForkService { /// Attempts to download the `Fork` from the server. async fn do_update(self) -> Result<(), ()> { - let log = self.context.log(); - let fork = self .inner .beacon_node @@ -145,14 +168,14 @@ impl ForkService { .await .map_err(|e| { trace!( - log, + self.log, "Fork update failed"; "error" => format!("Error retrieving fork: {:?}", e) ) })? .ok_or_else(|| { trace!( - log, + self.log, "Fork update failed"; "error" => "The beacon head fork is unknown" ) @@ -163,7 +186,7 @@ impl ForkService { *(self.fork.write()) = Some(fork); } - debug!(log, "Fork update success"); + debug!(self.log, "Fork update success"); // Returning an error will stop the interval. This is not desired, a single failure // should not stop all future attempts. diff --git a/validator_client/src/http_api/api_secret.rs b/validator_client/src/http_api/api_secret.rs new file mode 100644 index 000000000..a3aa5f0b9 --- /dev/null +++ b/validator_client/src/http_api/api_secret.rs @@ -0,0 +1,184 @@ +use eth2::lighthouse_vc::{PK_LEN, SECRET_PREFIX as PK_PREFIX}; +use rand::thread_rng; +use ring::digest::{digest, SHA256}; +use secp256k1::{Message, PublicKey, SecretKey}; +use std::fs; +use std::path::Path; +use warp::Filter; + +/// The name of the file which stores the secret key. +/// +/// It is purposefully opaque to prevent users confusing it with the "secret" that they need to +/// share with API consumers (which is actually the public key). +pub const SK_FILENAME: &str = ".secp-sk"; + +/// Length of the raw secret key, in bytes. +pub const SK_LEN: usize = 32; + +/// The name of the file which stores the public key. +/// +/// For users, this public key is a "secret" that can be shared with API consumers to provide them +/// access to the API. We avoid calling it a "public" key to users, since they should not post this +/// value in a public forum. +pub const PK_FILENAME: &str = "api-token.txt"; + +/// Contains a `secp256k1` keypair that is saved-to/loaded-from disk on instantiation. The keypair +/// is used for authorization/authentication for requests/responses on the HTTP API. +/// +/// Provides convenience functions to ultimately provide: +/// +/// - A signature across outgoing HTTP responses, applied to the `Signature` header. +/// - Verification of proof-of-knowledge of the public key in `self` for incoming HTTP requests, +/// via the `Authorization` header. +/// +/// The aforementioned scheme was first defined here: +/// +/// https://github.com/sigp/lighthouse/issues/1269#issuecomment-649879855 +pub struct ApiSecret { + pk: PublicKey, + sk: SecretKey, +} + +impl ApiSecret { + /// If both the secret and public keys are already on-disk, parse them and ensure they're both + /// from the same keypair. + /// + /// The provided `dir` is a directory containing two files, `SK_FILENAME` and `PK_FILENAME`. + /// + /// If either the secret or public key files are missing on disk, create a new keypair and + /// write it to disk (over-writing any existing files). + pub fn create_or_open>(dir: P) -> Result { + let sk_path = dir.as_ref().join(SK_FILENAME); + let pk_path = dir.as_ref().join(PK_FILENAME); + + if !(sk_path.exists() && pk_path.exists()) { + let sk = SecretKey::random(&mut thread_rng()); + let pk = PublicKey::from_secret_key(&sk); + + fs::write( + &sk_path, + serde_utils::hex::encode(&sk.serialize()).as_bytes(), + ) + .map_err(|e| e.to_string())?; + fs::write( + &pk_path, + format!( + "{}{}", + PK_PREFIX, + serde_utils::hex::encode(&pk.serialize_compressed()[..]) + ) + .as_bytes(), + ) + .map_err(|e| e.to_string())?; + } + + let sk = fs::read(&sk_path) + .map_err(|e| format!("cannot read {}: {}", SK_FILENAME, e)) + .and_then(|bytes| { + serde_utils::hex::decode(&String::from_utf8_lossy(&bytes)) + .map_err(|_| format!("{} should be 0x-prefixed hex", PK_FILENAME)) + }) + .and_then(|bytes| { + if bytes.len() == SK_LEN { + let mut array = [0; SK_LEN]; + array.copy_from_slice(&bytes); + SecretKey::parse(&array).map_err(|e| format!("invalid {}: {}", SK_FILENAME, e)) + } else { + Err(format!( + "{} expected {} bytes not {}", + SK_FILENAME, + SK_LEN, + bytes.len() + )) + } + })?; + + let pk = fs::read(&pk_path) + .map_err(|e| format!("cannot read {}: {}", PK_FILENAME, e)) + .and_then(|bytes| { + let hex = + String::from_utf8(bytes).map_err(|_| format!("{} is not utf8", SK_FILENAME))?; + if hex.starts_with(PK_PREFIX) { + serde_utils::hex::decode(&hex[PK_PREFIX.len()..]) + .map_err(|_| format!("{} should be 0x-prefixed hex", SK_FILENAME)) + } else { + Err(format!("unable to parse {}", SK_FILENAME)) + } + }) + .and_then(|bytes| { + if bytes.len() == PK_LEN { + let mut array = [0; PK_LEN]; + array.copy_from_slice(&bytes); + PublicKey::parse_compressed(&array) + .map_err(|e| format!("invalid {}: {}", PK_FILENAME, e)) + } else { + Err(format!( + "{} expected {} bytes not {}", + PK_FILENAME, + PK_LEN, + bytes.len() + )) + } + })?; + + // Ensure that the keys loaded from disk are indeed a pair. + if PublicKey::from_secret_key(&sk) != pk { + fs::remove_file(&sk_path) + .map_err(|e| format!("unable to remove {}: {}", SK_FILENAME, e))?; + fs::remove_file(&pk_path) + .map_err(|e| format!("unable to remove {}: {}", PK_FILENAME, e))?; + return Err(format!( + "{:?} does not match {:?} and the files have been deleted. Please try again.", + sk_path, pk_path + )); + } + + Ok(Self { sk, pk }) + } + + /// Returns the public key of `self` as a 0x-prefixed hex string. + fn pubkey_string(&self) -> String { + serde_utils::hex::encode(&self.pk.serialize_compressed()[..]) + } + + /// Returns the API token. + pub fn api_token(&self) -> String { + format!("{}{}", PK_PREFIX, self.pubkey_string()) + } + + /// Returns the value of the `Authorization` header which is used for verifying incoming HTTP + /// requests. + fn auth_header_value(&self) -> String { + format!("Basic {}", self.api_token()) + } + + /// Returns a `warp` header which filters out request that have a missing or inaccurate + /// `Authorization` header. + pub fn authorization_header_filter(&self) -> warp::filters::BoxedFilter<()> { + let expected = self.auth_header_value(); + warp::any() + .map(move || expected.clone()) + .and(warp::filters::header::header("Authorization")) + .and_then(move |expected: String, header: String| async move { + if header == expected { + Ok(()) + } else { + Err(warp_utils::reject::invalid_auth(header)) + } + }) + .untuple_one() + .boxed() + } + + /// Returns a closure which produces a signature over some bytes using the secret key in + /// `self`. The signature is a 32-byte hash formatted as a 0x-prefixed string. + pub fn signer(&self) -> impl Fn(&[u8]) -> String + Clone { + let sk = self.sk.clone(); + move |input: &[u8]| -> String { + let message = + Message::parse_slice(digest(&SHA256, input).as_ref()).expect("sha256 is 32 bytes"); + let (signature, _) = secp256k1::sign(&message, &sk); + serde_utils::hex::encode(signature.serialize_der().as_ref()) + } + } +} diff --git a/validator_client/src/http_api/create_validator.rs b/validator_client/src/http_api/create_validator.rs new file mode 100644 index 000000000..b84f9d614 --- /dev/null +++ b/validator_client/src/http_api/create_validator.rs @@ -0,0 +1,151 @@ +use crate::ValidatorStore; +use account_utils::{ + eth2_wallet::{bip39::Mnemonic, WalletBuilder}, + random_mnemonic, random_password, ZeroizeString, +}; +use eth2::lighthouse_vc::types::{self as api_types}; +use slot_clock::SlotClock; +use std::path::Path; +use types::ChainSpec; +use types::EthSpec; +use validator_dir::Builder as ValidatorDirBuilder; + +/// Create some validator EIP-2335 keystores and store them on disk. Then, enroll the validators in +/// this validator client. +/// +/// Returns the list of created validators and the mnemonic used to derive them via EIP-2334. +/// +/// ## Detail +/// +/// If `mnemonic_opt` is not supplied it will be randomly generated and returned in the response. +/// +/// If `key_derivation_path_offset` is supplied then the EIP-2334 validator index will start at +/// this point. +pub fn create_validators, T: 'static + SlotClock, E: EthSpec>( + mnemonic_opt: Option, + key_derivation_path_offset: Option, + validator_requests: &[api_types::ValidatorRequest], + validator_dir: P, + validator_store: &ValidatorStore, + spec: &ChainSpec, +) -> Result<(Vec, Mnemonic), warp::Rejection> { + let mnemonic = mnemonic_opt.unwrap_or_else(random_mnemonic); + + let wallet_password = random_password(); + let mut wallet = + WalletBuilder::from_mnemonic(&mnemonic, wallet_password.as_bytes(), String::new()) + .and_then(|builder| builder.build()) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "unable to create EIP-2386 wallet: {:?}", + e + )) + })?; + + if let Some(nextaccount) = key_derivation_path_offset { + wallet.set_nextaccount(nextaccount).map_err(|()| { + warp_utils::reject::custom_server_error("unable to set wallet nextaccount".to_string()) + })?; + } + + let mut validators = Vec::with_capacity(validator_requests.len()); + + for request in validator_requests { + let voting_password = random_password(); + let withdrawal_password = random_password(); + let voting_password_string = ZeroizeString::from( + String::from_utf8(voting_password.as_bytes().to_vec()).map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "locally generated password is not utf8: {:?}", + e + )) + })?, + ); + + let mut keystores = wallet + .next_validator( + wallet_password.as_bytes(), + voting_password.as_bytes(), + withdrawal_password.as_bytes(), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "unable to create validator keys: {:?}", + e + )) + })?; + + keystores + .voting + .set_description(request.description.clone()); + keystores + .withdrawal + .set_description(request.description.clone()); + + let voting_pubkey = format!("0x{}", keystores.voting.pubkey()) + .parse() + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "created invalid public key: {:?}", + e + )) + })?; + + let validator_dir = ValidatorDirBuilder::new(validator_dir.as_ref().into()) + .voting_keystore(keystores.voting, voting_password.as_bytes()) + .withdrawal_keystore(keystores.withdrawal, withdrawal_password.as_bytes()) + .create_eth1_tx_data(request.deposit_gwei, &spec) + .store_withdrawal_keystore(false) + .build() + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to build validator directory: {:?}", + e + )) + })?; + + let eth1_deposit_data = validator_dir + .eth1_deposit_data() + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to read local deposit data: {:?}", + e + )) + })? + .ok_or_else(|| { + warp_utils::reject::custom_server_error( + "failed to create local deposit data: {:?}".to_string(), + ) + })?; + + if eth1_deposit_data.deposit_data.amount != request.deposit_gwei { + return Err(warp_utils::reject::custom_server_error(format!( + "invalid deposit_gwei {}, expected {}", + eth1_deposit_data.deposit_data.amount, request.deposit_gwei + ))); + } + + tokio::runtime::Handle::current() + .block_on(validator_store.add_validator_keystore( + validator_dir.voting_keystore_path(), + voting_password_string, + request.enable, + )) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to initialize validator: {:?}", + e + )) + })?; + + validators.push(api_types::CreatedValidator { + enabled: request.enable, + description: request.description.clone(), + voting_pubkey, + eth1_deposit_tx_data: serde_utils::hex::encode(ð1_deposit_data.rlp), + deposit_gwei: request.deposit_gwei, + }); + } + + Ok((validators, mnemonic)) +} diff --git a/validator_client/src/http_api/mod.rs b/validator_client/src/http_api/mod.rs new file mode 100644 index 000000000..7e0d387d2 --- /dev/null +++ b/validator_client/src/http_api/mod.rs @@ -0,0 +1,488 @@ +mod api_secret; +mod create_validator; +mod tests; + +use crate::ValidatorStore; +use account_utils::mnemonic_from_phrase; +use create_validator::create_validators; +use eth2::lighthouse_vc::types::{self as api_types, PublicKey, PublicKeyBytes}; +use lighthouse_version::version_with_platform; +use serde::{Deserialize, Serialize}; +use slog::{crit, info, Logger}; +use slot_clock::SlotClock; +use std::future::Future; +use std::marker::PhantomData; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::path::PathBuf; +use std::sync::Arc; +use types::{ChainSpec, EthSpec, YamlConfig}; +use validator_dir::Builder as ValidatorDirBuilder; +use warp::{ + http::{ + header::{HeaderValue, CONTENT_TYPE}, + response::Response, + StatusCode, + }, + Filter, +}; + +pub use api_secret::ApiSecret; + +#[derive(Debug)] +pub enum Error { + Warp(warp::Error), + Other(String), +} + +impl From for Error { + fn from(e: warp::Error) -> Self { + Error::Warp(e) + } +} + +impl From for Error { + fn from(e: String) -> Self { + Error::Other(e) + } +} + +/// A wrapper around all the items required to spawn the HTTP server. +/// +/// The server will gracefully handle the case where any fields are `None`. +pub struct Context { + pub api_secret: ApiSecret, + pub validator_store: Option>, + pub validator_dir: Option, + pub spec: ChainSpec, + pub config: Config, + pub log: Logger, + pub _phantom: PhantomData, +} + +/// Configuration for the HTTP server. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub enabled: bool, + pub listen_addr: Ipv4Addr, + pub listen_port: u16, + pub allow_origin: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled: false, + listen_addr: Ipv4Addr::new(127, 0, 0, 1), + listen_port: 5062, + allow_origin: None, + } + } +} + +/// Creates a server that will serve requests using information from `ctx`. +/// +/// The server will shut down gracefully when the `shutdown` future resolves. +/// +/// ## Returns +/// +/// This function will bind the server to the provided address and then return a tuple of: +/// +/// - `SocketAddr`: the address that the HTTP server will listen on. +/// - `Future`: the actual server future that will need to be awaited. +/// +/// ## Errors +/// +/// Returns an error if the server is unable to bind or there is another error during +/// configuration. +pub fn serve( + ctx: Arc>, + shutdown: impl Future + Send + Sync + 'static, +) -> Result<(SocketAddr, impl Future), Error> { + let config = &ctx.config; + let log = ctx.log.clone(); + let allow_origin = config.allow_origin.clone(); + + // Sanity check. + if !config.enabled { + crit!(log, "Cannot start disabled metrics HTTP server"); + return Err(Error::Other( + "A disabled metrics server should not be started".to_string(), + )); + } + + let authorization_header_filter = ctx.api_secret.authorization_header_filter(); + let api_token = ctx.api_secret.api_token(); + let signer = ctx.api_secret.signer(); + let signer = warp::any().map(move || signer.clone()); + + let inner_validator_store = ctx.validator_store.clone(); + let validator_store_filter = warp::any() + .map(move || inner_validator_store.clone()) + .and_then(|validator_store: Option<_>| async move { + validator_store.ok_or_else(|| { + warp_utils::reject::custom_not_found( + "validator store is not initialized.".to_string(), + ) + }) + }); + + let inner_validator_dir = ctx.validator_dir.clone(); + let validator_dir_filter = warp::any() + .map(move || inner_validator_dir.clone()) + .and_then(|validator_dir: Option<_>| async move { + validator_dir.ok_or_else(|| { + warp_utils::reject::custom_not_found( + "validator_dir directory is not initialized.".to_string(), + ) + }) + }); + + let inner_spec = Arc::new(ctx.spec.clone()); + let spec_filter = warp::any().map(move || inner_spec.clone()); + + // GET lighthouse/version + let get_node_version = warp::path("lighthouse") + .and(warp::path("version")) + .and(warp::path::end()) + .and(signer.clone()) + .and_then(|signer| { + blocking_signed_json_task(signer, move || { + Ok(api_types::GenericResponse::from(api_types::VersionData { + version: version_with_platform(), + })) + }) + }); + + // GET lighthouse/health + let get_lighthouse_health = warp::path("lighthouse") + .and(warp::path("health")) + .and(warp::path::end()) + .and(signer.clone()) + .and_then(|signer| { + blocking_signed_json_task(signer, move || { + eth2::lighthouse::Health::observe() + .map(api_types::GenericResponse::from) + .map_err(warp_utils::reject::custom_bad_request) + }) + }); + + // GET lighthouse/spec + let get_lighthouse_spec = warp::path("lighthouse") + .and(warp::path("spec")) + .and(warp::path::end()) + .and(spec_filter.clone()) + .and(signer.clone()) + .and_then(|spec: Arc<_>, signer| { + blocking_signed_json_task(signer, move || { + Ok(api_types::GenericResponse::from( + YamlConfig::from_spec::(&spec), + )) + }) + }); + + // GET lighthouse/validators + let get_lighthouse_validators = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(signer.clone()) + .and_then(|validator_store: ValidatorStore, signer| { + blocking_signed_json_task(signer, move || { + let validators = validator_store + .initialized_validators() + .read() + .validator_definitions() + .iter() + .map(|def| api_types::ValidatorData { + enabled: def.enabled, + description: def.description.clone(), + voting_pubkey: PublicKeyBytes::from(&def.voting_public_key), + }) + .collect::>(); + + Ok(api_types::GenericResponse::from(validators)) + }) + }); + + // GET lighthouse/validators/{validator_pubkey} + let get_lighthouse_validators_pubkey = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path::param::()) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(signer.clone()) + .and_then( + |validator_pubkey: PublicKey, validator_store: ValidatorStore, signer| { + blocking_signed_json_task(signer, move || { + let validator = validator_store + .initialized_validators() + .read() + .validator_definitions() + .iter() + .find(|def| def.voting_public_key == validator_pubkey) + .map(|def| api_types::ValidatorData { + enabled: def.enabled, + description: def.description.clone(), + voting_pubkey: PublicKeyBytes::from(&def.voting_public_key), + }) + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "no validator for {:?}", + validator_pubkey + )) + })?; + + Ok(api_types::GenericResponse::from(validator)) + }) + }, + ); + + // POST lighthouse/validators/ + let post_validators = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path::end()) + .and(warp::body::json()) + .and(validator_dir_filter.clone()) + .and(validator_store_filter.clone()) + .and(spec_filter.clone()) + .and(signer.clone()) + .and_then( + |body: Vec, + validator_dir: PathBuf, + validator_store: ValidatorStore, + spec: Arc, + signer| { + blocking_signed_json_task(signer, move || { + let (validators, mnemonic) = create_validators( + None, + None, + &body, + &validator_dir, + &validator_store, + &spec, + )?; + let response = api_types::PostValidatorsResponseData { + mnemonic: mnemonic.into_phrase().into(), + validators, + }; + Ok(api_types::GenericResponse::from(response)) + }) + }, + ); + + // POST lighthouse/validators/mnemonic + let post_validators_mnemonic = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path("mnemonic")) + .and(warp::path::end()) + .and(warp::body::json()) + .and(validator_dir_filter.clone()) + .and(validator_store_filter.clone()) + .and(spec_filter) + .and(signer.clone()) + .and_then( + |body: api_types::CreateValidatorsMnemonicRequest, + validator_dir: PathBuf, + validator_store: ValidatorStore, + spec: Arc, + signer| { + blocking_signed_json_task(signer, move || { + let mnemonic = mnemonic_from_phrase(body.mnemonic.as_str()).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid mnemonic: {:?}", e)) + })?; + let (validators, _mnemonic) = create_validators( + Some(mnemonic), + Some(body.key_derivation_path_offset), + &body.validators, + &validator_dir, + &validator_store, + &spec, + )?; + Ok(api_types::GenericResponse::from(validators)) + }) + }, + ); + + // POST lighthouse/validators/keystore + let post_validators_keystore = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path("keystore")) + .and(warp::path::end()) + .and(warp::body::json()) + .and(validator_dir_filter) + .and(validator_store_filter.clone()) + .and(signer.clone()) + .and_then( + |body: api_types::KeystoreValidatorsPostRequest, + validator_dir: PathBuf, + validator_store: ValidatorStore, + signer| { + blocking_signed_json_task(signer, move || { + // Check to ensure the password is correct. + let keypair = body + .keystore + .decrypt_keypair(body.password.as_ref()) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid keystore: {:?}", + e + )) + })?; + + let validator_dir = ValidatorDirBuilder::new(validator_dir.clone()) + .voting_keystore(body.keystore.clone(), body.password.as_ref()) + .store_withdrawal_keystore(false) + .build() + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to build validator directory: {:?}", + e + )) + })?; + + let voting_password = body.password.clone(); + + let validator_def = tokio::runtime::Handle::current() + .block_on(validator_store.add_validator_keystore( + validator_dir.voting_keystore_path(), + voting_password, + body.enable, + )) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to initialize validator: {:?}", + e + )) + })?; + + Ok(api_types::GenericResponse::from(api_types::ValidatorData { + enabled: body.enable, + description: validator_def.description, + voting_pubkey: keypair.pk.into(), + })) + }) + }, + ); + + // PATCH lighthouse/validators/{validator_pubkey} + let patch_validators = warp::path("lighthouse") + .and(warp::path("validators")) + .and(warp::path::param::()) + .and(warp::path::end()) + .and(warp::body::json()) + .and(validator_store_filter) + .and(signer) + .and_then( + |validator_pubkey: PublicKey, + body: api_types::ValidatorPatchRequest, + validator_store: ValidatorStore, + signer| { + blocking_signed_json_task(signer, move || { + let initialized_validators_rw_lock = validator_store.initialized_validators(); + let mut initialized_validators = initialized_validators_rw_lock.write(); + + match initialized_validators.is_enabled(&validator_pubkey) { + None => Err(warp_utils::reject::custom_not_found(format!( + "no validator for {:?}", + validator_pubkey + ))), + Some(enabled) if enabled == body.enabled => Ok(()), + Some(_) => { + tokio::runtime::Handle::current() + .block_on( + initialized_validators + .set_validator_status(&validator_pubkey, body.enabled), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "unable to set validator status: {:?}", + e + )) + })?; + + Ok(()) + } + } + }) + }, + ); + + let routes = warp::any() + .and(authorization_header_filter) + .and( + warp::get().and( + get_node_version + .or(get_lighthouse_health) + .or(get_lighthouse_spec) + .or(get_lighthouse_validators) + .or(get_lighthouse_validators_pubkey), + ), + ) + .or(warp::post().and( + post_validators + .or(post_validators_keystore) + .or(post_validators_mnemonic), + )) + .or(warp::patch().and(patch_validators)) + // Maps errors into HTTP responses. + .recover(warp_utils::reject::handle_rejection) + // Add a `Server` header. + .map(|reply| warp::reply::with_header(reply, "Server", &version_with_platform())) + // Maybe add some CORS headers. + .map(move |reply| warp_utils::reply::maybe_cors(reply, allow_origin.as_ref())); + + let (listening_socket, server) = warp::serve(routes).try_bind_with_graceful_shutdown( + SocketAddrV4::new(config.listen_addr, config.listen_port), + async { + shutdown.await; + }, + )?; + + info!( + log, + "HTTP API started"; + "listen_address" => listening_socket.to_string(), + "api_token" => api_token, + ); + + Ok((listening_socket, server)) +} + +/// Executes `func` in blocking tokio task (i.e., where long-running tasks are permitted). +/// JSON-encodes the return value of `func`, using the `signer` function to produce a signature of +/// those bytes. +pub async fn blocking_signed_json_task( + signer: S, + func: F, +) -> Result +where + S: Fn(&[u8]) -> String, + F: Fn() -> Result, + T: Serialize, +{ + warp_utils::task::blocking_task(func) + .await + .map(|func_output| { + let mut response = match serde_json::to_vec(&func_output) { + Ok(body) => { + let mut res = Response::new(body); + res.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + res + } + Err(_) => Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(vec![]) + .expect("can produce simple response from static values"), + }; + + let body: &Vec = response.body(); + let signature = signer(body); + let header_value = + HeaderValue::from_str(&signature).expect("hash can be encoded as header"); + + response.headers_mut().append("Signature", header_value); + + response + }) +} diff --git a/validator_client/src/http_api/tests.rs b/validator_client/src/http_api/tests.rs new file mode 100644 index 000000000..e9344b5f4 --- /dev/null +++ b/validator_client/src/http_api/tests.rs @@ -0,0 +1,527 @@ +#![cfg(test)] +#![cfg(not(debug_assertions))] + +use crate::{ + http_api::{ApiSecret, Config as HttpConfig, Context}, + Config, ForkServiceBuilder, InitializedValidators, ValidatorDefinitions, ValidatorStore, +}; +use account_utils::{ + eth2_wallet::WalletBuilder, mnemonic_from_phrase, random_mnemonic, random_password, + ZeroizeString, +}; +use deposit_contract::decode_eth1_tx_data; +use environment::null_logger; +use eth2::{ + lighthouse_vc::{http_client::ValidatorClientHttpClient, types::*}, + Url, +}; +use eth2_keystore::KeystoreBuilder; +use parking_lot::RwLock; +use slot_clock::TestingSlotClock; +use std::marker::PhantomData; +use std::net::Ipv4Addr; +use std::sync::Arc; +use tempfile::{tempdir, TempDir}; +use tokio::sync::oneshot; + +const PASSWORD_BYTES: &[u8] = &[42, 13, 37]; + +type E = MainnetEthSpec; + +struct ApiTester { + client: ValidatorClientHttpClient, + initialized_validators: Arc>, + url: Url, + _server_shutdown: oneshot::Sender<()>, + _validator_dir: TempDir, +} + +impl ApiTester { + pub async fn new() -> Self { + let log = null_logger().unwrap(); + + let validator_dir = tempdir().unwrap(); + let secrets_dir = tempdir().unwrap(); + + let validator_defs = ValidatorDefinitions::open_or_create(validator_dir.path()).unwrap(); + + let initialized_validators = InitializedValidators::from_definitions( + validator_defs, + validator_dir.path().into(), + false, + log.clone(), + ) + .await + .unwrap(); + + let api_secret = ApiSecret::create_or_open(validator_dir.path()).unwrap(); + let api_pubkey = api_secret.api_token(); + + let mut config = Config::default(); + config.validator_dir = validator_dir.path().into(); + config.secrets_dir = secrets_dir.path().into(); + + let fork_service = ForkServiceBuilder::testing_only(log.clone()) + .build() + .unwrap(); + + let validator_store: ValidatorStore = ValidatorStore::new( + initialized_validators, + &config, + Hash256::repeat_byte(42), + E::default_spec(), + fork_service.clone(), + log.clone(), + ) + .unwrap(); + + let initialized_validators = validator_store.initialized_validators(); + + let context: Arc> = Arc::new(Context { + api_secret, + validator_dir: Some(validator_dir.path().into()), + validator_store: Some(validator_store), + spec: E::default_spec(), + config: HttpConfig { + enabled: true, + listen_addr: Ipv4Addr::new(127, 0, 0, 1), + listen_port: 0, + allow_origin: None, + }, + log, + _phantom: PhantomData, + }); + let ctx = context.clone(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server_shutdown = async { + // It's not really interesting why this triggered, just that it happened. + let _ = shutdown_rx.await; + }; + let (listening_socket, server) = super::serve(ctx, server_shutdown).unwrap(); + + tokio::spawn(async { server.await }); + + let url = Url::parse(&format!( + "http://{}:{}", + listening_socket.ip(), + listening_socket.port() + )) + .unwrap(); + + let client = ValidatorClientHttpClient::new(url.clone(), api_pubkey).unwrap(); + + Self { + initialized_validators, + _validator_dir: validator_dir, + client, + url, + _server_shutdown: shutdown_tx, + } + } + + pub fn invalidate_api_token(mut self) -> Self { + let tmp = tempdir().unwrap(); + let api_secret = ApiSecret::create_or_open(tmp.path()).unwrap(); + let invalid_pubkey = api_secret.api_token(); + + self.client = ValidatorClientHttpClient::new(self.url.clone(), invalid_pubkey).unwrap(); + self + } + + pub async fn test_get_lighthouse_version_invalid(self) -> Self { + self.client.get_lighthouse_version().await.unwrap_err(); + self + } + + pub async fn test_get_lighthouse_spec(self) -> Self { + let result = self.client.get_lighthouse_spec().await.unwrap().data; + + let expected = YamlConfig::from_spec::(&E::default_spec()); + + assert_eq!(result, expected); + + self + } + + pub async fn test_get_lighthouse_version(self) -> Self { + let result = self.client.get_lighthouse_version().await.unwrap().data; + + let expected = VersionData { + version: lighthouse_version::version_with_platform(), + }; + + assert_eq!(result, expected); + + self + } + + #[cfg(target_os = "linux")] + pub async fn test_get_lighthouse_health(self) -> Self { + self.client.get_lighthouse_health().await.unwrap(); + + self + } + + #[cfg(not(target_os = "linux"))] + pub async fn test_get_lighthouse_health(self) -> Self { + self.client.get_lighthouse_health().await.unwrap_err(); + + self + } + pub fn vals_total(&self) -> usize { + self.initialized_validators.read().num_total() + } + + pub fn vals_enabled(&self) -> usize { + self.initialized_validators.read().num_enabled() + } + + pub fn assert_enabled_validators_count(self, count: usize) -> Self { + assert_eq!(self.vals_enabled(), count); + self + } + + pub fn assert_validators_count(self, count: usize) -> Self { + assert_eq!(self.vals_total(), count); + self + } + + pub async fn create_hd_validators(self, s: HdValidatorScenario) -> Self { + let initial_vals = self.vals_total(); + let initial_enabled_vals = self.vals_enabled(); + + let validators = (0..s.count) + .map(|i| ValidatorRequest { + enable: !s.disabled.contains(&i), + description: format!("boi #{}", i), + deposit_gwei: E::default_spec().max_effective_balance, + }) + .collect::>(); + + let (response, mnemonic) = if s.specify_mnemonic { + let mnemonic = ZeroizeString::from(random_mnemonic().phrase().to_string()); + let request = CreateValidatorsMnemonicRequest { + mnemonic: mnemonic.clone(), + key_derivation_path_offset: s.key_derivation_path_offset, + validators: validators.clone(), + }; + let response = self + .client + .post_lighthouse_validators_mnemonic(&request) + .await + .unwrap() + .data; + + (response, mnemonic) + } else { + assert_eq!( + s.key_derivation_path_offset, 0, + "cannot use a derivation offset without specifying a mnemonic" + ); + let response = self + .client + .post_lighthouse_validators(validators.clone()) + .await + .unwrap() + .data; + (response.validators.clone(), response.mnemonic.clone()) + }; + + assert_eq!(response.len(), s.count); + assert_eq!(self.vals_total(), initial_vals + s.count); + assert_eq!( + self.vals_enabled(), + initial_enabled_vals + s.count - s.disabled.len() + ); + + let server_vals = self.client.get_lighthouse_validators().await.unwrap().data; + + assert_eq!(server_vals.len(), self.vals_total()); + + // Ensure the server lists all of these newly created validators. + for validator in &response { + assert!(server_vals + .iter() + .any(|server_val| server_val.voting_pubkey == validator.voting_pubkey)); + } + + /* + * Verify that we can regenerate all the keys from the mnemonic. + */ + + let mnemonic = mnemonic_from_phrase(mnemonic.as_str()).unwrap(); + let mut wallet = WalletBuilder::from_mnemonic(&mnemonic, PASSWORD_BYTES, "".to_string()) + .unwrap() + .build() + .unwrap(); + + wallet + .set_nextaccount(s.key_derivation_path_offset) + .unwrap(); + + for i in 0..s.count { + let keypairs = wallet + .next_validator(PASSWORD_BYTES, PASSWORD_BYTES, PASSWORD_BYTES) + .unwrap(); + let voting_keypair = keypairs.voting.decrypt_keypair(PASSWORD_BYTES).unwrap(); + + assert_eq!( + response[i].voting_pubkey, + voting_keypair.pk.clone().into(), + "the locally generated voting pk should match the server response" + ); + + let withdrawal_keypair = keypairs.withdrawal.decrypt_keypair(PASSWORD_BYTES).unwrap(); + + let deposit_bytes = + serde_utils::hex::decode(&response[i].eth1_deposit_tx_data).unwrap(); + + let (deposit_data, _) = + decode_eth1_tx_data(&deposit_bytes, E::default_spec().max_effective_balance) + .unwrap(); + + assert_eq!( + deposit_data.pubkey, + voting_keypair.pk.clone().into(), + "the locally generated voting pk should match the deposit data" + ); + + assert_eq!( + deposit_data.withdrawal_credentials, + Hash256::from_slice(&bls::get_withdrawal_credentials( + &withdrawal_keypair.pk, + E::default_spec().bls_withdrawal_prefix_byte + )), + "the locally generated withdrawal creds should match the deposit data" + ); + + assert_eq!( + deposit_data.signature, + deposit_data.create_signature(&voting_keypair.sk, &E::default_spec()), + "the locally-generated deposit sig should create the same deposit sig" + ); + } + + self + } + + pub async fn create_keystore_validators(self, s: KeystoreValidatorScenario) -> Self { + let initial_vals = self.vals_total(); + let initial_enabled_vals = self.vals_enabled(); + + let password = random_password(); + let keypair = Keypair::random(); + let keystore = KeystoreBuilder::new(&keypair, password.as_bytes(), String::new()) + .unwrap() + .build() + .unwrap(); + + if !s.correct_password { + let request = KeystoreValidatorsPostRequest { + enable: s.enabled, + password: String::from_utf8(random_password().as_ref().to_vec()) + .unwrap() + .into(), + keystore, + }; + + self.client + .post_lighthouse_validators_keystore(&request) + .await + .unwrap_err(); + + return self; + } + + let request = KeystoreValidatorsPostRequest { + enable: s.enabled, + password: String::from_utf8(password.as_ref().to_vec()) + .unwrap() + .into(), + keystore, + }; + + let response = self + .client + .post_lighthouse_validators_keystore(&request) + .await + .unwrap() + .data; + + let num_enabled = s.enabled as usize; + + assert_eq!(self.vals_total(), initial_vals + 1); + assert_eq!(self.vals_enabled(), initial_enabled_vals + num_enabled); + + let server_vals = self.client.get_lighthouse_validators().await.unwrap().data; + + assert_eq!(server_vals.len(), self.vals_total()); + + assert_eq!(response.voting_pubkey, keypair.pk.into()); + assert_eq!(response.enabled, s.enabled); + + self + } + + pub async fn set_validator_enabled(self, index: usize, enabled: bool) -> Self { + let validator = &self.client.get_lighthouse_validators().await.unwrap().data[index]; + + self.client + .patch_lighthouse_validators(&validator.voting_pubkey, enabled) + .await + .unwrap(); + + assert_eq!( + self.initialized_validators + .read() + .is_enabled(&validator.voting_pubkey.decompress().unwrap()) + .unwrap(), + enabled + ); + + assert!(self + .client + .get_lighthouse_validators() + .await + .unwrap() + .data + .into_iter() + .find(|v| v.voting_pubkey == validator.voting_pubkey) + .map(|v| v.enabled == enabled) + .unwrap()); + + // Check the server via an individual request. + assert_eq!( + self.client + .get_lighthouse_validators_pubkey(&validator.voting_pubkey) + .await + .unwrap() + .unwrap() + .data + .enabled, + enabled + ); + + self + } +} + +struct HdValidatorScenario { + count: usize, + specify_mnemonic: bool, + key_derivation_path_offset: u32, + disabled: Vec, +} + +struct KeystoreValidatorScenario { + enabled: bool, + correct_password: bool, +} + +#[tokio::test(core_threads = 2)] +async fn invalid_pubkey() { + ApiTester::new() + .await + .invalidate_api_token() + .test_get_lighthouse_version_invalid() + .await; +} + +#[tokio::test(core_threads = 2)] +async fn simple_getters() { + ApiTester::new() + .await + .test_get_lighthouse_version() + .await + .test_get_lighthouse_health() + .await + .test_get_lighthouse_spec() + .await; +} + +#[tokio::test(core_threads = 2)] +async fn hd_validator_creation() { + ApiTester::new() + .await + .assert_enabled_validators_count(0) + .assert_validators_count(0) + .create_hd_validators(HdValidatorScenario { + count: 2, + specify_mnemonic: true, + key_derivation_path_offset: 0, + disabled: vec![], + }) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(2) + .create_hd_validators(HdValidatorScenario { + count: 1, + specify_mnemonic: false, + key_derivation_path_offset: 0, + disabled: vec![0], + }) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(3) + .create_hd_validators(HdValidatorScenario { + count: 0, + specify_mnemonic: true, + key_derivation_path_offset: 4, + disabled: vec![], + }) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(3); +} + +#[tokio::test(core_threads = 2)] +async fn validator_enabling() { + ApiTester::new() + .await + .create_hd_validators(HdValidatorScenario { + count: 2, + specify_mnemonic: false, + key_derivation_path_offset: 0, + disabled: vec![], + }) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(2) + .set_validator_enabled(0, false) + .await + .assert_enabled_validators_count(1) + .assert_validators_count(2) + .set_validator_enabled(0, true) + .await + .assert_enabled_validators_count(2) + .assert_validators_count(2); +} + +#[tokio::test(core_threads = 2)] +async fn keystore_validator_creation() { + ApiTester::new() + .await + .assert_enabled_validators_count(0) + .assert_validators_count(0) + .create_keystore_validators(KeystoreValidatorScenario { + correct_password: true, + enabled: true, + }) + .await + .assert_enabled_validators_count(1) + .assert_validators_count(1) + .create_keystore_validators(KeystoreValidatorScenario { + correct_password: false, + enabled: true, + }) + .await + .assert_enabled_validators_count(1) + .assert_validators_count(1) + .create_keystore_validators(KeystoreValidatorScenario { + correct_password: true, + enabled: false, + }) + .await + .assert_enabled_validators_count(1) + .assert_validators_count(2); +} diff --git a/validator_client/src/initialized_validators.rs b/validator_client/src/initialized_validators.rs index a097d7245..dbab008e8 100644 --- a/validator_client/src/initialized_validators.rs +++ b/validator_client/src/initialized_validators.rs @@ -56,6 +56,8 @@ pub enum Error { TokioJoin(tokio::task::JoinError), /// There was a filesystem error when deleting a lockfile. UnableToDeleteLockfile(io::Error), + /// Cannot initialize the same validator twice. + DuplicatePublicKey, } /// A method used by a validator to sign messages. @@ -322,6 +324,42 @@ impl InitializedValidators { .map(|v| v.voting_keypair()) } + /// Add a validator definition to `self`, overwriting the on-disk representation of `self`. + pub async fn add_definition(&mut self, def: ValidatorDefinition) -> Result<(), Error> { + if self + .definitions + .as_slice() + .iter() + .any(|existing| existing.voting_public_key == def.voting_public_key) + { + return Err(Error::DuplicatePublicKey); + } + + self.definitions.push(def); + + self.update_validators().await?; + + self.definitions + .save(&self.validators_dir) + .map_err(Error::UnableToSaveDefinitions)?; + + Ok(()) + } + + /// Returns a slice of all defined validators (regardless of their enabled state). + pub fn validator_definitions(&self) -> &[ValidatorDefinition] { + self.definitions.as_slice() + } + + /// Indicates if the `voting_public_key` exists in self and if it is enabled. + pub fn is_enabled(&self, voting_public_key: &PublicKey) -> Option { + self.definitions + .as_slice() + .iter() + .find(|def| def.voting_public_key == *voting_public_key) + .map(|def| def.enabled) + } + /// Sets the `InitializedValidator` and `ValidatorDefinition` `enabled` values. /// /// ## Notes diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 8a0e8ba1e..034271199 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -10,6 +10,8 @@ mod notifier; mod validator_duty; mod validator_store; +pub mod http_api; + pub use cli::cli_app; pub use config::Config; @@ -22,11 +24,14 @@ use environment::RuntimeContext; use eth2::{reqwest::ClientBuilder, BeaconNodeHttpClient, StatusCode, Url}; use fork_service::{ForkService, ForkServiceBuilder}; use futures::channel::mpsc; +use http_api::ApiSecret; use initialized_validators::InitializedValidators; use notifier::spawn_notifier; use slog::{error, info, Logger}; use slot_clock::SlotClock; use slot_clock::SystemTimeSlotClock; +use std::marker::PhantomData; +use std::net::SocketAddr; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::time::{delay_for, Duration}; @@ -42,9 +47,11 @@ const HTTP_TIMEOUT: Duration = Duration::from_secs(12); pub struct ProductionValidatorClient { context: RuntimeContext, duties_service: DutiesService, - fork_service: ForkService, + fork_service: ForkService, block_service: BlockService, attestation_service: AttestationService, + validator_store: ValidatorStore, + http_api_listen_addr: Option, config: Config, } @@ -55,7 +62,7 @@ impl ProductionValidatorClient { context: RuntimeContext, cli_args: &ArgMatches<'_>, ) -> Result { - let config = Config::from_cli(&cli_args) + let config = Config::from_cli(&cli_args, context.log()) .map_err(|e| format!("Unable to initialize config: {}", e))?; Self::new(context, config).await } @@ -68,7 +75,7 @@ impl ProductionValidatorClient { info!( log, "Starting validator client"; - "beacon_node" => &config.http_server, + "beacon_node" => &config.beacon_node, "validator_dir" => format!("{:?}", config.validator_dir), ); @@ -106,7 +113,7 @@ impl ProductionValidatorClient { ); let beacon_node_url: Url = config - .http_server + .beacon_node .parse() .map_err(|e| format!("Unable to parse beacon node URL: {:?}", e))?; let beacon_node_http_client = ClientBuilder::new() @@ -144,7 +151,7 @@ impl ProductionValidatorClient { let fork_service = ForkServiceBuilder::new() .slot_clock(slot_clock.clone()) .beacon_node(beacon_node.clone()) - .runtime_context(context.service_context("fork".into())) + .log(log.clone()) .build()?; let validator_store: ValidatorStore = ValidatorStore::new( @@ -183,7 +190,7 @@ impl ProductionValidatorClient { let attestation_service = AttestationServiceBuilder::new() .duties_service(duties_service.clone()) .slot_clock(slot_clock) - .validator_store(validator_store) + .validator_store(validator_store.clone()) .beacon_node(beacon_node) .runtime_context(context.service_context("attestation".into())) .build()?; @@ -194,7 +201,9 @@ impl ProductionValidatorClient { fork_service, block_service, attestation_service, + validator_store, config, + http_api_listen_addr: None, }) } @@ -204,6 +213,7 @@ impl ProductionValidatorClient { // whole epoch! let channel_capacity = T::slots_per_epoch() as usize; let (block_service_tx, block_service_rx) = mpsc::channel(channel_capacity); + let log = self.context.log(); self.duties_service .clone() @@ -215,7 +225,7 @@ impl ProductionValidatorClient { self.fork_service .clone() - .start_update_service(&self.context.eth2_config.spec) + .start_update_service(&self.context) .map_err(|e| format!("Unable to start fork service: {}", e))?; self.block_service @@ -230,6 +240,35 @@ impl ProductionValidatorClient { spawn_notifier(self).map_err(|e| format!("Failed to start notifier: {}", e))?; + let api_secret = ApiSecret::create_or_open(&self.config.validator_dir)?; + + self.http_api_listen_addr = if self.config.http_api.enabled { + let ctx: Arc> = Arc::new(http_api::Context { + api_secret, + validator_store: Some(self.validator_store.clone()), + validator_dir: Some(self.config.validator_dir.clone()), + spec: self.context.eth2_config.spec.clone(), + config: self.config.http_api.clone(), + log: log.clone(), + _phantom: PhantomData, + }); + + let exit = self.context.executor.exit(); + + let (listen_addr, server) = http_api::serve(ctx, exit) + .map_err(|e| format!("Unable to start HTTP API server: {:?}", e))?; + + self.context + .clone() + .executor + .spawn_without_exit(async move { server.await }, "http-api"); + + Some(listen_addr) + } else { + info!(log, "HTTP API server is disabled"); + None + }; + Ok(()) } } diff --git a/validator_client/src/notifier.rs b/validator_client/src/notifier.rs index d9ee7faec..c997979b9 100644 --- a/validator_client/src/notifier.rs +++ b/validator_client/src/notifier.rs @@ -45,7 +45,12 @@ pub fn spawn_notifier(client: &ProductionValidatorClient) -> Resu let attesting_validators = duties_service.attester_count(epoch); if total_validators == 0 { - error!(log, "No validators present") + info!( + log, + "No validators present"; + "msg" => "see `lighthouse account validator create --help` \ + or the HTTP API documentation" + ) } else if total_validators == attesting_validators { info!( log, diff --git a/validator_client/src/validator_store.rs b/validator_client/src/validator_store.rs index 6bf2f211d..e8d57f148 100644 --- a/validator_client/src/validator_store.rs +++ b/validator_client/src/validator_store.rs @@ -1,11 +1,13 @@ use crate::{ config::Config, fork_service::ForkService, initialized_validators::InitializedValidators, }; +use account_utils::{validator_definitions::ValidatorDefinition, ZeroizeString}; use parking_lot::RwLock; use slashing_protection::{NotSafe, Safe, SlashingDatabase, SLASHING_PROTECTION_FILENAME}; use slog::{crit, error, warn, Logger}; use slot_clock::SlotClock; use std::marker::PhantomData; +use std::path::Path; use std::sync::Arc; use tempdir::TempDir; use types::{ @@ -47,7 +49,7 @@ pub struct ValidatorStore { spec: Arc, log: Logger, temp_dir: Option>, - fork_service: ForkService, + fork_service: ForkService, _phantom: PhantomData, } @@ -57,7 +59,7 @@ impl ValidatorStore { config: &Config, genesis_validators_root: Hash256, spec: ChainSpec, - fork_service: ForkService, + fork_service: ForkService, log: Logger, ) -> Result { let slashing_db_path = config.validator_dir.join(SLASHING_PROTECTION_FILENAME); @@ -91,6 +93,43 @@ impl ValidatorStore { }) } + pub fn initialized_validators(&self) -> Arc> { + self.validators.clone() + } + + /// Insert a new validator to `self`, where the validator is represented by an EIP-2335 + /// keystore on the filesystem. + /// + /// This function includes: + /// + /// - Add the validator definition to the YAML file, saving it to the filesystem. + /// - Enable validator with the slashing protection database. + /// - If `enable == true`, start performing duties for the validator. + pub async fn add_validator_keystore>( + &self, + voting_keystore_path: P, + password: ZeroizeString, + enable: bool, + ) -> Result { + let mut validator_def = + ValidatorDefinition::new_keystore_with_password(voting_keystore_path, Some(password)) + .map_err(|e| format!("failed to create validator definitions: {:?}", e))?; + + self.slashing_protection + .register_validator(&validator_def.voting_public_key) + .map_err(|e| format!("failed to register validator: {:?}", e))?; + + validator_def.enabled = enable; + + self.validators + .write() + .add_definition(validator_def.clone()) + .await + .map_err(|e| format!("Unable to add definition: {:?}", e))?; + + Ok(validator_def) + } + /// Register all known validators with the slashing protection database. /// /// Registration is required to protect against a lost or missing slashing database,