diff --git a/Cargo.lock b/Cargo.lock index fa1d36f3c1..e5641cda50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3311,6 +3311,7 @@ dependencies = [ "futures 0.3.7", "lighthouse_version", "logging", + "remote_signer", "slashing_protection", "slog", "slog-async", @@ -4745,6 +4746,70 @@ version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" +[[package]] +name = "remote_signer" +version = "0.2.0" +dependencies = [ + "bls", + "clap", + "environment", + "remote_signer_backend", + "remote_signer_client", + "remote_signer_test", + "serde_json", + "slog", + "types", +] + +[[package]] +name = "remote_signer_backend" +version = "0.2.0" +dependencies = [ + "bls", + "clap", + "hex", + "lazy_static", + "regex", + "remote_signer_test", + "slog", + "sloggers", + "tempdir", + "types", + "zeroize", +] + +[[package]] +name = "remote_signer_client" +version = "0.2.0" +dependencies = [ + "clap", + "environment", + "futures 0.3.7", + "hyper 0.13.9", + "lazy_static", + "regex", + "remote_signer_backend", + "serde", + "serde_json", + "slog", + "task_executor", + "types", +] + +[[package]] +name = "remote_signer_test" +version = "0.2.0" +dependencies = [ + "clap", + "environment", + "remote_signer_client", + "reqwest", + "serde", + "serde_json", + "tempdir", + "types", +] + [[package]] name = "remove_dir_all" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index d15b23be68..3913f16d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,10 +63,15 @@ members = [ "lighthouse", "lighthouse/environment", - "testing/simulator", + "remote_signer", + "remote_signer/backend", + "remote_signer/client", + "testing/ef_tests", "testing/eth1_test_rig", "testing/node_test_rig", + "testing/remote_signer_test", + "testing/simulator", "testing/state_transition_vectors", "validator_client", diff --git a/Makefile b/Makefile index 823620788a..bd020bfac1 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,6 @@ build-release-tarballs: $(MAKE) build-aarch64-portable $(call tarball_release_binary,$(BUILD_PATH_AARCH64),$(AARCH64_TAG),"-portable") - # Runs the full workspace tests in **release**, without downloading any additional # test vectors. test-release: diff --git a/lighthouse/Cargo.toml b/lighthouse/Cargo.toml index 9a39e154e3..6fe115c5cd 100644 --- a/lighthouse/Cargo.toml +++ b/lighthouse/Cargo.toml @@ -36,6 +36,7 @@ eth2_testnet_config = { path = "../common/eth2_testnet_config" } directory = { path = "../common/directory" } lighthouse_version = { path = "../common/lighthouse_version" } account_utils = { path = "../common/account_utils" } +remote_signer = { "path" = "../remote_signer" } [dev-dependencies] tempfile = "3.1.0" diff --git a/lighthouse/environment/src/lib.rs b/lighthouse/environment/src/lib.rs index 7f7eb8216e..f6801801d1 100644 --- a/lighthouse/environment/src/lib.rs +++ b/lighthouse/environment/src/lib.rs @@ -15,7 +15,7 @@ use futures::channel::{ }; use futures::{future, StreamExt}; -use slog::{info, o, Drain, Level, Logger}; +use slog::{error, info, o, Drain, Level, Logger}; use sloggers::{null::NullLoggerBuilder, Build}; use std::cell::RefCell; use std::ffi::OsStr; @@ -395,9 +395,16 @@ impl Environment { // setup for handling a Ctrl-C let (ctrlc_send, ctrlc_oneshot) = oneshot::channel(); let ctrlc_send_c = RefCell::new(Some(ctrlc_send)); + let log = self.log.clone(); ctrlc::set_handler(move || { if let Some(ctrlc_send) = ctrlc_send_c.try_borrow_mut().unwrap().take() { - ctrlc_send.send(()).expect("Error sending ctrl-c message"); + if let Err(e) = ctrlc_send.send(()) { + error!( + log, + "Error sending ctrl-c message"; + "error" => e + ); + } } }) .map_err(|e| format!("Could not set ctrlc handler: {:?}", e))?; diff --git a/lighthouse/src/main.rs b/lighthouse/src/main.rs index 23bb540708..dff10f5995 100644 --- a/lighthouse/src/main.rs +++ b/lighthouse/src/main.rs @@ -125,6 +125,7 @@ fn main() { .subcommand(boot_node::cli_app()) .subcommand(validator_client::cli_app()) .subcommand(account_manager::cli_app()) + .subcommand(remote_signer::cli_app()) .get_matches(); // Debugging output for libp2p and external crates. @@ -292,7 +293,7 @@ fn run( .shutdown_sender() .try_send("Failed to start beacon node"); } - }) + }); } ("validator_client", Some(matches)) => { let context = environment.core_context(); @@ -316,7 +317,17 @@ fn run( .shutdown_sender() .try_send("Failed to start validator client"); } - }) + }); + } + ("remote_signer", Some(matches)) => { + if let Err(e) = remote_signer::run(&mut environment, matches) { + crit!(log, "Failed to start remote signer"; "reason" => e); + let _ = environment + .core_context() + .executor + .shutdown_sender() + .try_send("Failed to start remote signer"); + } } _ => { crit!(log, "No subcommand supplied. See --help ."); diff --git a/remote_signer/Cargo.toml b/remote_signer/Cargo.toml new file mode 100644 index 0000000000..a3b1fff638 --- /dev/null +++ b/remote_signer/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "remote_signer" +version = "0.2.0" +authors = ["Sigma Prime "] +edition = "2018" + +[features] +# Compiles the BLS crypto code so that the binary is portable across machines. +portable = ["bls/supranational-portable"] +# Uses the slower Milagro BLS library, which is written in native Rust. +milagro = ["bls/milagro"] + +[dev-dependencies] +client_backend = { path = "./backend", package = "remote_signer_backend" } +helpers = { path = "../testing/remote_signer_test", package = "remote_signer_test" } + +[dependencies] +bls = { path = "../crypto/bls" } +clap = "2.33.3" +client = { path = "./client", package = "remote_signer_client" } +environment = { path = "../lighthouse/environment" } +serde_json = "1.0.58" +slog = { version = "2.5.2", features = ["max_level_trace"] } +types = { path = "../consensus/types"} diff --git a/remote_signer/README.md b/remote_signer/README.md new file mode 100644 index 0000000000..67f83c7f2a --- /dev/null +++ b/remote_signer/README.md @@ -0,0 +1,137 @@ +# Remote BLS Signer + +## Overview + +Simple HTTP BLS signer service. + +This service is designed to be consumed by Ethereum 2.0 clients, looking for a more secure avenue to store their BLS12-381 secret keys, while running their validators in more permisive and/or scalable environments. + +One goal of this package is to be standard compliant. There is a [current draft for an Ethereum Improvement Proposal (EIP)](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-3030.md) in progress. Please refer to the [wishlist](#wishlist--roadmap) in this very document for a list of advanced features. + +## API + +### Standard + +### `GET /upcheck` + +_**Responses**_ + +Success |
+--- | --- +Code | `200` +Content | `{"status": "OK"}` + +--- + +### `GET /keys` + +Returns the identifiers of the keys available to the signer. + +_**Responses**_ + +Success |
+--- | --- +Code | `200` +Content | `{"keys": "[identifier]"}` + +--- + +### `POST /sign/:identifier` + +URL Parameter |
+--- | --- +`:identifier` | `public_key_hex_string_without_0x` + +_**Request**_ + +JSON Body |
|
+--- | --- | --- +`bls_domain` | **Required** | The BLS Signature domain.
As defined in the [specification](https://github.com/ethereum/eth2.0-specs/blob/dev/specs/phase0/beacon-chain.md#domain-types), in lowercase, omitting the `domain` prefix.
Supporting `beacon_proposer`, `beacon_attester`, and `randao`. +`data` | **Required** | The data to be signed.
As defined in the specifications for [block](https://github.com/ethereum/eth2.0-APIs/blob/master/types/block.yaml), [attestation](https://github.com/ethereum/eth2.0-APIs/blob/master/types/attestation.yaml), and [epoch](https://github.com/ethereum/eth2.0-APIs/blob/master/types/misc.yaml). +`fork` | **Required** | A `Fork` object containing previous and current versions.
As defined in the [specification](https://github.com/ethereum/eth2.0-APIs/blob/master/types/misc.yaml) +`genesis_validators_root` | **Required** | A `Hash256` for domain separation and chain versioning. +
| Optional | Any other field will be ignored by the signer + +_**Responses**_ + +Success |
+--- | --- +Code | `200` +Content | `{"signature": ""}` + +_or_ + +Error |
+--- | --- +Code | `400` +Content | `{"error": ""}` + +_or_ + +Error |
+--- | --- +Code | `404` +Content | `{"error": "Key not found: "}` + +## Build instructions + +1. [Get Rust](https://www.rust-lang.org/learn/get-started). +2. Go to the root directory of this repository. +3. Execute `make` +4. The binary `lighthouse` will most likely be found in `./target/release`. +5. Run it as `lighthouse remote_signer` or `lighthouse rs`. + +## Running the signer + +### Storing the secret keys as raw files + +* Steps to store a secret key + * Choose an empty directory, as the backend will parse every file looking for keys. + * Create a file named after the **hex representation of the public key without 0x**. + * Write the **hex representation of the secret key without 0x**. + * Store the file in your chosen directory. + * Use this directory as a command line parameter (`--storage-raw-dir`) + +### Command line flags + +``` +USAGE: + remote_signer [OPTIONS] + +FLAGS: + -h, --help Prints help information + -V, --version Prints version information + +OPTIONS: + --debug-level The verbosity level for emitting logs. [default: info] [possible values: + info, debug, trace, warn, error, crit] + --listen-address
The address to listen for TCP connections. [default: 0.0.0.0] + --log-format Specifies the format used for logging. [possible values: JSON] + --logfile File path where output will be written. + --port The TCP port to listen on. [default: 9000] + --spec Specifies the default eth2 spec type. [default: mainnet] [possible values: + mainnet, minimal, interop] + --storage-raw-dir <DIR> Data directory for secret keys in raw files. +``` + +## Roadmap + +- [X] EIP standard compliant +- [ ] Metrics +- [ ] Benchmarking & Profiling +- [ ] Release management +- [ ] Architecture builds +- [ ] Support EIP-2335, BLS12-381 keystore +- [ ] Support storage in AWS Cloud HSM +- [ ] Route with the `warp` library +- [ ] Filter by the `message` field + - [ ] Middleware REST API + - [ ] Built-in middleware + - [ ] Flag to enforce the `message` field and compare it to the signing root +- [ ] TLS/SSL support for requests +- [ ] Authentication by HTTP Header support +- [ ] Confidential computing support (e.g. Intel SGX) + +## LICENSE + +* Apache 2.0. diff --git a/remote_signer/backend/Cargo.toml b/remote_signer/backend/Cargo.toml new file mode 100644 index 0000000000..e819f5dbd0 --- /dev/null +++ b/remote_signer/backend/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "remote_signer_backend" +version = "0.2.0" +authors = ["Herman Junge <herman@sigmaprime.io>"] +edition = "2018" + +[dev-dependencies] +helpers = { path = "../../testing/remote_signer_test", package = "remote_signer_test" } +sloggers = "1.0.1" +tempdir = "0.3.7" + +[dependencies] +bls = { path = "../../crypto/bls" } +clap = "2.33.3" +hex = "0.4.2" +lazy_static = "1.4.0" +regex = "1.3.9" +slog = "2.5.2" +types = { path = "../../consensus/types" } +zeroize = { version = "1.1.1", features = ["zeroize_derive"] } diff --git a/remote_signer/backend/src/error.rs b/remote_signer/backend/src/error.rs new file mode 100644 index 0000000000..63a87710c5 --- /dev/null +++ b/remote_signer/backend/src/error.rs @@ -0,0 +1,46 @@ +#[derive(Debug)] +pub enum BackendError { + /// Parameter is not a hexadecimal representation of a BLS public key. + InvalidPublicKey(String), + + /// Retrieved value is not a hexadecimal representation of a BLS secret key. + InvalidSecretKey(String), + + /// Public and Secret key won't match. + KeyMismatch(String), + + /// Item requested by its public key is not found. + KeyNotFound(String), + + /// Errors from the storage medium. + /// + /// When converted from `std::io::Error`, stores `std::io::ErrorKind` + /// and `std::io::Error` both formatted to string. + StorageError(String, String), +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + BackendError::InvalidPublicKey(e) => write!(f, "Invalid public key: {}", e), + + // Feed it with the public key value used to retrieve it. + BackendError::InvalidSecretKey(e) => write!(f, "Invalid secret key: {}", e), + + // Feed it with the public key value used to retrieve it. + BackendError::KeyMismatch(e) => write!(f, "Key mismatch: {}", e), + + BackendError::KeyNotFound(e) => write!(f, "Key not found: {}", e), + + // Only outputs to string the first component of the tuple, accounting + // for potential differences on error displays between OS distributions. + BackendError::StorageError(e, _) => write!(f, "Storage error: {}", e), + } + } +} + +impl From<std::io::Error> for BackendError { + fn from(e: std::io::Error) -> BackendError { + BackendError::StorageError(format!("{:?}", e.kind()), format!("{}", e)) + } +} diff --git a/remote_signer/backend/src/lib.rs b/remote_signer/backend/src/lib.rs new file mode 100644 index 0000000000..b2a799adec --- /dev/null +++ b/remote_signer/backend/src/lib.rs @@ -0,0 +1,336 @@ +mod error; +mod storage; +mod storage_raw_dir; +mod utils; +mod zeroize_string; + +use crate::zeroize_string::ZeroizeString; +use bls::SecretKey; +use clap::ArgMatches; +pub use error::BackendError; +use lazy_static::lazy_static; +use regex::Regex; +use slog::{info, Logger}; +pub use storage::Storage; +use storage_raw_dir::StorageRawDir; +use types::Hash256; +use utils::{bytes96_to_hex_string, validate_bls_pair}; + +lazy_static! { + static ref PUBLIC_KEY_REGEX: Regex = Regex::new(r"[0-9a-fA-F]{96}").unwrap(); +} + +/// A backend to be used by the Remote Signer HTTP API. +/// +/// Designed to support several types of storages. +#[derive(Clone)] +pub struct Backend<T> { + storage: T, +} + +impl Backend<StorageRawDir> { + /// Creates a Backend with the given storage type at the CLI arguments. + /// + /// # Storage types supported + /// + /// * Raw files in directory: `--storage-raw-dir <DIR>` + /// + pub fn new(cli_args: &ArgMatches<'_>, log: &Logger) -> Result<Self, String> { + // Storage types are mutually exclusive. + if let Some(path) = cli_args.value_of("storage-raw-dir") { + info!( + log, + "Loading Backend"; + "storage type" => "raw dir", + "directory" => path + ); + + StorageRawDir::new(path) + .map(|storage| Self { storage }) + .map_err(|e| format!("Storage Raw Dir: {}", e)) + } else { + Err("No storage type supplied.".to_string()) + } + } +} + +impl<T: Storage> Backend<T> { + /// Returns the available public keys in storage. + pub fn get_keys(&self) -> Result<Vec<String>, BackendError> { + self.storage.get_keys() + } + + /// Signs the message with the requested key in storage. + pub fn sign_message( + &self, + public_key: &str, + signing_root: Hash256, + ) -> Result<String, BackendError> { + if !PUBLIC_KEY_REGEX.is_match(public_key) || public_key.len() != 96 { + return Err(BackendError::InvalidPublicKey(public_key.to_string())); + } + + let secret_key: ZeroizeString = self.storage.get_secret_key(public_key)?; + let secret_key: SecretKey = validate_bls_pair(public_key, secret_key)?; + + let signature = secret_key.sign(signing_root); + + let signature: String = bytes96_to_hex_string(signature.serialize()) + .expect("Writing to a string should never error."); + + Ok(signature) + } +} + +#[cfg(test)] +pub mod tests_commons { + use super::*; + pub use crate::Storage; + use helpers::*; + use sloggers::{null::NullLoggerBuilder, Build}; + use tempdir::TempDir; + + type T = StorageRawDir; + + pub fn new_storage_with_tmp_dir() -> (T, TempDir) { + let tmp_dir = TempDir::new("bls-remote-signer-test").unwrap(); + let storage = StorageRawDir::new(tmp_dir.path().to_str().unwrap()).unwrap(); + (storage, tmp_dir) + } + + pub fn get_null_logger() -> Logger { + let log_builder = NullLoggerBuilder; + log_builder.build().unwrap() + } + + pub fn new_backend_for_get_keys() -> (Backend<T>, TempDir) { + let tmp_dir = TempDir::new("bls-remote-signer-test").unwrap(); + + let matches = set_matches(vec![ + "this_test", + "--storage-raw-dir", + tmp_dir.path().to_str().unwrap(), + ]); + + let backend = match Backend::new(&matches, &get_null_logger()) { + Ok(backend) => (backend), + Err(e) => panic!("We should not be getting an err here: {}", e), + }; + + (backend, tmp_dir) + } + + pub fn new_backend_for_signing() -> (Backend<T>, TempDir) { + let (backend, tmp_dir) = new_backend_for_get_keys(); + + // This one has the whole fauna. + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + add_mismatched_key_file(&tmp_dir); + + (backend, tmp_dir) + } + + pub fn assert_backend_new_error(matches: &ArgMatches, error_msg: &str) { + match Backend::new(matches, &get_null_logger()) { + Ok(_) => panic!("This invocation to Backend::new() should return error"), + Err(e) => assert_eq!(e.to_string(), error_msg), + } + } +} + +#[cfg(test)] +pub mod backend_new { + use super::*; + use crate::tests_commons::*; + use helpers::*; + use tempdir::TempDir; + + #[test] + fn no_storage_type_supplied() { + let matches = set_matches(vec!["this_test"]); + + assert_backend_new_error(&matches, "No storage type supplied."); + } + + #[test] + fn given_path_does_not_exist() { + let matches = set_matches(vec!["this_test", "--storage-raw-dir", "/dev/null/foo"]); + + assert_backend_new_error(&matches, "Storage Raw Dir: Path does not exist."); + } + + #[test] + fn given_path_is_not_a_dir() { + let matches = set_matches(vec!["this_test", "--storage-raw-dir", "/dev/null"]); + + assert_backend_new_error(&matches, "Storage Raw Dir: Path is not a directory."); + } + + #[test] + fn given_inaccessible() { + let tmp_dir = TempDir::new("bls-remote-signer-test").unwrap(); + set_permissions(tmp_dir.path(), 0o40311); + + let matches = set_matches(vec![ + "this_test", + "--storage-raw-dir", + tmp_dir.path().to_str().unwrap(), + ]); + + let result = Backend::new(&matches, &get_null_logger()); + + // A `d-wx--x--x` directory is innaccesible but not unwrittable. + // By switching back to `drwxr-xr-x` we can get rid of the + // temporal directory once we leave this scope. + set_permissions(tmp_dir.path(), 0o40755); + + match result { + Ok(_) => panic!("This invocation to Backend::new() should return error"), + Err(e) => assert_eq!(e.to_string(), "Storage Raw Dir: PermissionDenied",), + } + } + + #[test] + fn happy_path() { + let (_backend, _tmp_dir) = new_backend_for_get_keys(); + } +} + +#[cfg(test)] +pub mod backend_raw_dir_get_keys { + use crate::tests_commons::*; + use helpers::*; + + #[test] + fn empty_dir() { + let (backend, _tmp_dir) = new_backend_for_get_keys(); + + assert_eq!(backend.get_keys().unwrap().len(), 0); + } + + #[test] + fn some_files_are_not_public_keys() { + let (backend, tmp_dir) = new_backend_for_get_keys(); + + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + + assert_eq!(backend.get_keys().unwrap().len(), 3); + } + + #[test] + fn all_files_are_public_keys() { + let (backend, tmp_dir) = new_backend_for_get_keys(); + add_key_files(&tmp_dir); + + assert_eq!(backend.get_keys().unwrap().len(), 3); + } +} + +#[cfg(test)] +pub mod backend_raw_dir_sign_message { + use crate::tests_commons::*; + use helpers::*; + use types::Hash256; + + #[test] + fn invalid_public_key() { + let (backend, _tmp_dir) = new_backend_for_signing(); + + let test_case = |public_key_param: &str| { + assert_eq!( + backend + .clone() + .sign_message( + public_key_param, + Hash256::from_slice(&hex::decode(SIGNING_ROOT).unwrap()) + ) + .unwrap_err() + .to_string(), + format!("Invalid public key: {}", public_key_param) + ); + }; + + test_case("abcdef"); // Length < 96. + test_case(&format!("{}55", PUBLIC_KEY_1)); // Length > 96. + test_case(SILLY_FILE_NAME_1); // Length == 96; Invalid hex characters. + } + + #[test] + fn storage_error() { + let (backend, tmp_dir) = new_backend_for_signing(); + + set_permissions(tmp_dir.path(), 0o40311); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40311); + + let result = backend.sign_message( + PUBLIC_KEY_1, + Hash256::from_slice(&hex::decode(SIGNING_ROOT).unwrap()), + ); + + set_permissions(tmp_dir.path(), 0o40755); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40755); + + assert_eq!( + result.unwrap_err().to_string(), + "Storage error: PermissionDenied" + ); + } + + #[test] + fn key_not_found() { + let (backend, _tmp_dir) = new_backend_for_signing(); + + assert_eq!( + backend + .sign_message( + ABSENT_PUBLIC_KEY, + Hash256::from_slice(&hex::decode(SIGNING_ROOT).unwrap()) + ) + .unwrap_err() + .to_string(), + format!("Key not found: {}", ABSENT_PUBLIC_KEY) + ); + } + + #[test] + fn key_mismatch() { + let (backend, _tmp_dir) = new_backend_for_signing(); + + assert_eq!( + backend + .sign_message( + MISMATCHED_PUBLIC_KEY, + Hash256::from_slice(&hex::decode(SIGNING_ROOT).unwrap()) + ) + .unwrap_err() + .to_string(), + format!("Key mismatch: {}", MISMATCHED_PUBLIC_KEY) + ); + } + + #[test] + fn happy_path() { + let (backend, _tmp_dir) = new_backend_for_signing(); + + let test_case = |public_key: &str, signature: &str| { + assert_eq!( + backend + .clone() + .sign_message( + public_key, + Hash256::from_slice(&hex::decode(SIGNING_ROOT).unwrap()) + ) + .unwrap(), + signature + ); + }; + + test_case(PUBLIC_KEY_1, EXPECTED_SIGNATURE_1); + test_case(PUBLIC_KEY_2, EXPECTED_SIGNATURE_2); + test_case(PUBLIC_KEY_3, EXPECTED_SIGNATURE_3); + } +} diff --git a/remote_signer/backend/src/storage.rs b/remote_signer/backend/src/storage.rs new file mode 100644 index 0000000000..6b413c393d --- /dev/null +++ b/remote_signer/backend/src/storage.rs @@ -0,0 +1,10 @@ +use crate::{BackendError, ZeroizeString}; + +/// The storage medium for the secret keys used by a `Backend`. +pub trait Storage: 'static + Clone + Send + Sync { + /// Queries storage for the available keys to sign. + fn get_keys(&self) -> Result<Vec<String>, BackendError>; + + /// Retrieves secret key from storage, using its public key as reference. + fn get_secret_key(&self, input: &str) -> Result<ZeroizeString, BackendError>; +} diff --git a/remote_signer/backend/src/storage_raw_dir.rs b/remote_signer/backend/src/storage_raw_dir.rs new file mode 100644 index 0000000000..4990ca574e --- /dev/null +++ b/remote_signer/backend/src/storage_raw_dir.rs @@ -0,0 +1,181 @@ +use crate::{BackendError, Storage, ZeroizeString, PUBLIC_KEY_REGEX}; +use std::fs::read_dir; +use std::fs::File; +use std::io::prelude::Read; +use std::io::BufReader; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct StorageRawDir { + path: PathBuf, +} + +impl StorageRawDir { + /// Initializes the storage with the given path, verifying + /// whether it is a directory and if its available to the user. + /// Does not list, nor verify the contents of the directory. + pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, String> { + let path = path.as_ref(); + + if !path.exists() { + return Err("Path does not exist.".to_string()); + } + + if !path.is_dir() { + return Err("Path is not a directory.".to_string()); + } + + read_dir(path).map_err(|e| format!("{:?}", e.kind()))?; + + Ok(Self { + path: path.to_path_buf(), + }) + } +} + +impl Storage for StorageRawDir { + /// List all the files in the directory having a BLS public key name. + /// This function DOES NOT check the contents of each file. + fn get_keys(&self) -> Result<Vec<String>, BackendError> { + let entries = read_dir(&self.path).map_err(BackendError::from)?; + + // We are silently suppressing errors in this chain + // because we only care about files actually passing these filters. + let keys: Vec<String> = entries + .filter_map(|entry| entry.ok()) + .filter(|entry| !entry.path().is_dir()) + .map(|entry| entry.file_name().into_string()) + .filter_map(|entry| entry.ok()) + .filter(|name| PUBLIC_KEY_REGEX.is_match(name)) + .collect(); + + Ok(keys) + } + + /// Gets a requested secret key by their reference, its public key. + /// This function DOES NOT check the contents of the retrieved file. + fn get_secret_key(&self, input: &str) -> Result<ZeroizeString, BackendError> { + let file = File::open(self.path.join(input)).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => BackendError::KeyNotFound(input.to_string()), + _ => e.into(), + })?; + let mut buf_reader = BufReader::new(file); + + let mut secret_key = String::new(); + buf_reader.read_to_string(&mut secret_key)?; + + // Remove that `\n` without cloning. + secret_key.pop(); + + Ok(ZeroizeString::from(secret_key)) + } +} + +#[cfg(test)] +mod get_keys { + use crate::tests_commons::*; + use helpers::*; + + #[test] + fn problem_with_path() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_key_files(&tmp_dir); + + // All good and fancy, let's make the dir innacessible now. + set_permissions(tmp_dir.path(), 0o40311); + + let result = storage.get_keys(); + + // Give permissions back, we want the tempdir to be deleted. + set_permissions(tmp_dir.path(), 0o40755); + + assert_eq!( + result.unwrap_err().to_string(), + "Storage error: PermissionDenied" + ); + } + + #[test] + fn no_files_in_dir() { + let (storage, _tmp_dir) = new_storage_with_tmp_dir(); + + assert_eq!(storage.get_keys().unwrap().len(), 0); + } + + #[test] + fn no_files_in_dir_are_public_keys() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_sub_dirs(&tmp_dir); + add_non_key_files(&tmp_dir); + + assert_eq!(storage.get_keys().unwrap().len(), 0); + } + + #[test] + fn not_all_files_have_public_key_names() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + + assert_eq!(storage.get_keys().unwrap().len(), 3); + } + + #[test] + fn all_files_do_have_public_key_names() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_key_files(&tmp_dir); + + assert_eq!(storage.get_keys().unwrap().len(), 3); + } +} + +#[cfg(test)] +mod get_secret_key { + use crate::tests_commons::*; + use helpers::*; + + #[test] + fn unaccessible_file() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_key_files(&tmp_dir); + + set_permissions(tmp_dir.path(), 0o40311); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40311); + + let result = storage.get_secret_key(PUBLIC_KEY_1); + + set_permissions(tmp_dir.path(), 0o40755); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40755); + + assert_eq!( + result.unwrap_err().to_string(), + "Storage error: PermissionDenied" + ); + } + + #[test] + fn key_does_not_exist() { + let (storage, _tmp_dir) = new_storage_with_tmp_dir(); + + assert_eq!( + storage + .get_secret_key(PUBLIC_KEY_1) + .unwrap_err() + .to_string(), + format!("Key not found: {}", PUBLIC_KEY_1) + ); + } + + #[test] + fn happy_path() { + let (storage, tmp_dir) = new_storage_with_tmp_dir(); + add_key_files(&tmp_dir); + + assert_eq!( + storage.get_secret_key(PUBLIC_KEY_1).unwrap().as_ref(), + SECRET_KEY_1.as_bytes() + ); + } +} diff --git a/remote_signer/backend/src/utils.rs b/remote_signer/backend/src/utils.rs new file mode 100644 index 0000000000..56859a28e1 --- /dev/null +++ b/remote_signer/backend/src/utils.rs @@ -0,0 +1,123 @@ +use crate::{BackendError, ZeroizeString}; +use bls::SecretKey; +use hex::decode; +use std::fmt::{Error, Write}; +use std::str; + +// hex::encode only allows up to 32 bytes. +pub fn bytes96_to_hex_string(data: [u8; 96]) -> Result<String, Error> { + static CHARS: &[u8] = b"0123456789abcdef"; + let mut s = String::with_capacity(96 * 2 + 2); + + s.write_char('0')?; + s.write_char('x')?; + + for &byte in data.iter() { + s.write_char(CHARS[(byte >> 4) as usize].into())?; + s.write_char(CHARS[(byte & 0xf) as usize].into())?; + } + + Ok(s) +} + +/// Validates the match as a BLS pair of the public and secret keys given, +/// consuming the secret key parameter, and returning a deserialized SecretKey. +pub fn validate_bls_pair( + public_key: &str, + secret_key: ZeroizeString, +) -> Result<SecretKey, BackendError> { + let secret_key: SecretKey = secret_key.into_bls_sk().map_err(|e| { + BackendError::InvalidSecretKey(format!("public_key: {}; {}", public_key, e)) + })?; + + let pk_param_as_bytes = decode(&public_key) + .map_err(|e| BackendError::InvalidPublicKey(format!("{}; {}", public_key, e)))?; + + if &secret_key.public_key().serialize()[..] != pk_param_as_bytes.as_slice() { + return Err(BackendError::KeyMismatch(public_key.to_string())); + } + + Ok(secret_key) +} + +#[cfg(test)] +mod functions { + use super::*; + use helpers::*; + + #[test] + fn fn_bytes96_to_hex_string() { + assert_eq!( + bytes96_to_hex_string(EXPECTED_SIGNATURE_1_BYTES).unwrap(), + EXPECTED_SIGNATURE_1 + ); + + assert_eq!( + bytes96_to_hex_string(EXPECTED_SIGNATURE_2_BYTES).unwrap(), + EXPECTED_SIGNATURE_2 + ); + + assert_eq!( + bytes96_to_hex_string(EXPECTED_SIGNATURE_3_BYTES).unwrap(), + EXPECTED_SIGNATURE_3 + ); + } + + #[test] + fn fn_validate_bls_pair() { + let test_ok_case = |pk: &str, sk: ZeroizeString, sk_bytes: &[u8; 32]| { + let serialized_secret_key = validate_bls_pair(pk, sk).unwrap().serialize(); + assert_eq!(serialized_secret_key.as_bytes().to_vec(), sk_bytes.to_vec()); + }; + + test_ok_case( + PUBLIC_KEY_1, + ZeroizeString::from(SECRET_KEY_1.to_string()), + &SECRET_KEY_1_BYTES, + ); + + let test_error_case = |pk: &str, sk: ZeroizeString, expected_error: &str| { + assert_eq!( + validate_bls_pair(pk, sk).err().unwrap().to_string(), + expected_error + ); + }; + + test_error_case( + PUBLIC_KEY_2, + ZeroizeString::from("TamperedKey%#$#%#$$&##00£$%$$£%$".to_string()), + &format!( + "Invalid secret key: public_key: {}; Invalid hex character: T at index 0", + PUBLIC_KEY_2 + ), + ); + + test_error_case( + PUBLIC_KEY_2, + ZeroizeString::from("deadbeef".to_string()), + &format!( + "Invalid secret key: public_key: {}; InvalidSecretKeyLength {{ got: 4, expected: 32 }}", + PUBLIC_KEY_2 + ), + ); + + let bad_pk_param = "not_validated_by_the_api_handler!"; + test_error_case( + bad_pk_param, + ZeroizeString::from(SECRET_KEY_1.to_string()), + &format!("Invalid public key: {}; Odd number of digits", bad_pk_param), + ); + + test_error_case( + PUBLIC_KEY_1, + ZeroizeString::from(SECRET_KEY_2.to_string()), + &format!("Key mismatch: {}", PUBLIC_KEY_1), + ); + + test_error_case( + PUBLIC_KEY_2, + ZeroizeString::from(SECRET_KEY_3.to_string()), + &format!("Key mismatch: {}", PUBLIC_KEY_2), + ); + } +} diff --git a/remote_signer/backend/src/zeroize_string.rs b/remote_signer/backend/src/zeroize_string.rs new file mode 100644 index 0000000000..0d4630f3fa --- /dev/null +++ b/remote_signer/backend/src/zeroize_string.rs @@ -0,0 +1,222 @@ +use bls::SecretKey; +use std::str; +use zeroize::Zeroize; + +/// Provides a new-type wrapper around `String` that is zeroized on `Drop`. +/// +/// Useful for ensuring that secret key memory is zeroed-out on drop. +#[derive(Debug, Zeroize)] +#[zeroize(drop)] +pub struct ZeroizeString(String); + +impl From<String> for ZeroizeString { + fn from(s: String) -> Self { + Self(s) + } +} + +impl AsRef<[u8]> for ZeroizeString { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl ZeroizeString { + /// Consumes the ZeroizeString, attempting to return a BLS SecretKey. + pub fn into_bls_sk(self) -> Result<SecretKey, String> { + let mut decoded_bytes = hex_string_to_bytes(&self.0)?; + + let secret_key = SecretKey::deserialize(&decoded_bytes).map_err(|e| format!("{:?}", e))?; + decoded_bytes.zeroize(); + + Ok(secret_key) + } +} + +// An alternative to `hex::decode`, to allow for more control of +// the objects created while decoding the secret key. +fn hex_string_to_bytes(data: &str) -> Result<Vec<u8>, String> { + if data.len() % 2 != 0 { + return Err("Odd length".to_string()); + } + + let mut vec: Vec<u8> = Vec::new(); + for i in 0..data.len() / 2 { + vec.push( + val(&data.as_bytes()[2 * i], 2 * i)? << 4 + | val(&data.as_bytes()[2 * i + 1], 2 * i + 1)?, + ); + } + + Ok(vec) +} + +// Auxiliar function for `hex_string_to_bytes`. +fn val(c: &u8, idx: usize) -> Result<u8, String> { + match c { + b'A'..=b'F' => Ok(c - b'A' + 10), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'0'..=b'9' => Ok(c - b'0'), + _ => Err(format!( + "Invalid hex character: {} at index {}", + *c as char, idx + )), + } +} + +#[cfg(test)] +mod object { + use super::*; + use helpers::*; + use zeroize::Zeroize; + + #[test] + fn v_u8_zeroized() { + // Create from `hex_string_to_bytes`, and record the pointer to its buffer. + let mut decoded_bytes = hex_string_to_bytes(&SECRET_KEY_1.to_string()).unwrap(); + let old_pointer = decoded_bytes.as_ptr() as usize; + + // Do something with the borrowed vector, and zeroize. + let _ = SecretKey::deserialize(&decoded_bytes) + .map_err(|e| format!("{:?}", e)) + .unwrap(); + decoded_bytes.zeroize(); + + // Check it is pointing to the same buffer, and that it was deleted. + assert_eq!(old_pointer as usize, decoded_bytes.as_ptr() as usize); + assert!(decoded_bytes.is_empty()); + + // Check if the underlying bytes were zeroized. + for i in 0..SECRET_KEY_1.len() / 2 { + unsafe { + assert_eq!(*((old_pointer + i) as *const u8), 0); + } + } + } + + #[test] + fn fn_to_bls_sk() { + let test_ok_case = |sk: &str, sk_b: &[u8]| { + let z = ZeroizeString::from(sk.to_string()); + let sk: SecretKey = z.into_bls_sk().unwrap(); + assert_eq!(sk.serialize().as_bytes(), sk_b); + }; + + let test_error_case = |sk: &str, err_msg: &str| { + let z = ZeroizeString::from(sk.to_string()); + let err = z.into_bls_sk().err(); + assert_eq!(err, Some(err_msg.to_string())); + }; + + test_ok_case(SECRET_KEY_1, &SECRET_KEY_1_BYTES); + + test_error_case("Trolololololo", "Odd length"); + test_error_case("Trololololol", "Invalid hex character: T at index 0"); + test_error_case( + "そんなことないでしょうけどう", + "Invalid hex character: ã at index 0", + ); + } + + #[test] + fn zeroized_after_drop() { + let some_scope = |s: &str| -> usize { + // Convert our literal into a String, then store the pointer + // to the first byte of its slice. + let s: String = s.to_string(); + let s_ptr = s.as_ptr(); + + // Just to make sure that the pointer of the string is NOT + // the same as the pointer of the underlying buffer. + assert_ne!(&s as *const String as usize, s_ptr as usize); + + let z = ZeroizeString::from(s); + + // Get the pointer to the underlying buffer, + // We want to make sure is the same as the received string literal. + // That is, no copies of the contents. + let ptr_to_buf = z.as_ref().as_ptr(); + assert_eq!(ptr_to_buf, s_ptr); + + // We exit this scope, returning to the caller the pointer to + // the buffer, that we'll use to verify the zeroization. + ptr_to_buf as usize + }; + + // Call the closure. + let ptr_to_buf = some_scope(SECRET_KEY_1); + + // Check if the underlying bytes were zeroized. + // At this point the first half is already reclaimed and assigned, + // so we will just examine the other half. + for i in SECRET_KEY_1.len() / 2..SECRET_KEY_1.len() { + unsafe { + assert_eq!(*((ptr_to_buf + i) as *const u8), 0); + } + } + } +} + +#[cfg(test)] +mod functions { + use super::*; + use helpers::*; + + #[test] + fn fn_hex_string_to_bytes() { + assert_eq!( + hex_string_to_bytes(&"0aa".to_string()).err(), + Some("Odd length".to_string()) + ); + + assert_eq!( + hex_string_to_bytes(&"0xdeadbeef".to_string()).err(), + Some("Invalid hex character: x at index 1".to_string()) + ); + + assert_eq!( + hex_string_to_bytes(&"n00bn00b".to_string()).err(), + Some("Invalid hex character: n at index 0".to_string()) + ); + + assert_eq!( + hex_string_to_bytes(&"abcdefgh".to_string()).err(), + Some("Invalid hex character: g at index 6".to_string()) + ); + + assert_eq!( + hex_string_to_bytes(&SECRET_KEY_1).unwrap(), + SECRET_KEY_1_BYTES + ); + + assert_eq!( + hex_string_to_bytes(&PUBLIC_KEY_1).unwrap(), + PUBLIC_KEY_1_BYTES.to_vec() + ); + + assert_eq!( + hex_string_to_bytes(&SIGNING_ROOT).unwrap(), + SIGNING_ROOT_BYTES.to_vec() + ); + + assert_eq!( + hex_string_to_bytes(&EXPECTED_SIGNATURE_1[2..]).unwrap(), + EXPECTED_SIGNATURE_1_BYTES.to_vec() + ); + + assert_eq!( + hex_string_to_bytes(&EXPECTED_SIGNATURE_2[2..]).unwrap(), + EXPECTED_SIGNATURE_2_BYTES.to_vec() + ); + + assert_eq!( + hex_string_to_bytes(&EXPECTED_SIGNATURE_3[2..]).unwrap(), + EXPECTED_SIGNATURE_3_BYTES.to_vec() + ); + + assert_eq!( + hex_string_to_bytes(&"0a0b11".to_string()).unwrap(), + vec![10, 11, 17] + ); + } +} diff --git a/remote_signer/client/Cargo.toml b/remote_signer/client/Cargo.toml new file mode 100644 index 0000000000..dac014790e --- /dev/null +++ b/remote_signer/client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "remote_signer_client" +version = "0.2.0" +authors = ["Herman Junge <herman@sigmaprime.io>"] +edition = "2018" + +[dependencies] +clap = "2.33.3" +client_backend = { path = "../backend", package = "remote_signer_backend" } +environment = { path = "../../lighthouse/environment" } +futures = "0.3.6" +hyper = "0.13.8" +lazy_static = "1.4.0" +regex = "1.3.9" +serde = { version = "1.0.116", features = ["derive"] } +serde_json = "1.0.58" +slog = "2.5.2" +types = { path = "../../consensus/types" } +task_executor = { "path" = "../../common/task_executor" } diff --git a/remote_signer/client/src/api_error.rs b/remote_signer/client/src/api_error.rs new file mode 100644 index 0000000000..1fdb3ec879 --- /dev/null +++ b/remote_signer/client/src/api_error.rs @@ -0,0 +1,57 @@ +use hyper::{Body, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::to_string; +use std::error::Error as StdError; + +#[derive(PartialEq, Debug, Clone)] +pub enum ApiError { + ServerError(String), + NotImplemented(String), + BadRequest(String), + NotFound(String), +} + +#[derive(Deserialize, Serialize)] +pub struct ApiErrorDesc { + pub error: String, +} + +pub type ApiResult = Result<Response<Body>, ApiError>; + +impl ApiError { + pub fn status_code(self) -> (StatusCode, String) { + match self { + ApiError::ServerError(desc) => (StatusCode::INTERNAL_SERVER_ERROR, desc), + ApiError::NotImplemented(desc) => (StatusCode::NOT_IMPLEMENTED, desc), + ApiError::BadRequest(desc) => (StatusCode::BAD_REQUEST, desc), + ApiError::NotFound(desc) => (StatusCode::NOT_FOUND, desc), + } + } +} + +impl Into<Response<Body>> for ApiError { + fn into(self) -> Response<Body> { + let (status_code, desc) = self.status_code(); + + let json_desc = to_string(&ApiErrorDesc { error: desc }) + .expect("The struct ApiErrorDesc should always serialize."); + + Response::builder() + .status(status_code) + .body(Body::from(json_desc)) + .expect("Response should always be created.") + } +} + +impl StdError for ApiError { + fn cause(&self) -> Option<&dyn StdError> { + None + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let status = self.clone().status_code(); + write!(f, "{:?}: {:?}", status.0, status.1) + } +} diff --git a/remote_signer/client/src/api_response.rs b/remote_signer/client/src/api_response.rs new file mode 100644 index 0000000000..f65be75308 --- /dev/null +++ b/remote_signer/client/src/api_response.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct UpcheckApiResponse { + pub status: String, +} + +/// Contains the response to the `get_keys` API. +#[derive(Deserialize, Serialize)] +pub struct KeysApiResponse { + pub keys: Vec<String>, +} + +/// Contains the response to the `sign_message` API. +#[derive(Deserialize, Serialize)] +pub struct SignatureApiResponse { + pub signature: String, +} diff --git a/remote_signer/client/src/backend.rs b/remote_signer/client/src/backend.rs new file mode 100644 index 0000000000..3dfd8a210c --- /dev/null +++ b/remote_signer/client/src/backend.rs @@ -0,0 +1,70 @@ +use crate::api_error::ApiError; +use crate::api_response::{KeysApiResponse, SignatureApiResponse}; +use crate::rest_api::Context; +use crate::signing_root::get_signing_root; +use client_backend::{BackendError, Storage}; +use hyper::Request; +use lazy_static::lazy_static; +use regex::Regex; +use std::sync::Arc; +use types::EthSpec; + +lazy_static! { + static ref PUBLIC_KEY_FROM_PATH_REGEX: Regex = Regex::new(r"^/[^/]+/([^/]*)").unwrap(); +} + +/// HTTP handler to get the list of public keys in the backend. +pub fn get_keys<E: EthSpec, S: Storage, U>( + _: U, + ctx: Arc<Context<E, S>>, +) -> Result<KeysApiResponse, ApiError> { + let keys = ctx + .backend + .get_keys() + .map_err(|e| ApiError::ServerError(format!("{}", e)))?; + + if keys.is_empty() { + return Err(ApiError::NotFound("No keys found in storage.".to_string())); + } + + Ok(KeysApiResponse { keys }) +} + +/// HTTP handler to sign a message with the requested key. +pub fn sign_message<E: EthSpec, S: Storage>( + req: Request<Vec<u8>>, + ctx: Arc<Context<E, S>>, +) -> Result<SignatureApiResponse, ApiError> { + // Parse the request body and compute the signing root. + let signing_root = get_signing_root::<E>(&req, ctx.spec.clone())?; + + // This public key parameter should have been validated by the router. + // We are just going to extract it from the request. + let path = req.uri().path().to_string(); + + let rc = |path: &str| -> Result<String, String> { + let caps = PUBLIC_KEY_FROM_PATH_REGEX.captures(path).ok_or("")?; + let re_match = caps.get(1).ok_or("")?; + Ok(re_match.as_str().to_string()) + }; + + let public_key = rc(&path).map_err(|_| { + ApiError::BadRequest(format!("Unable to get public key from path: {:?}", path)) + })?; + + match ctx.backend.sign_message(&public_key, signing_root) { + Ok(signature) => Ok(SignatureApiResponse { signature }), + + Err(BackendError::KeyNotFound(_)) => { + Err(ApiError::NotFound(format!("Key not found: {}", public_key))) + } + + Err(BackendError::InvalidPublicKey(_)) => Err(ApiError::BadRequest(format!( + "Invalid public key: {}", + public_key + ))), + + // Catches InvalidSecretKey, KeyMismatch and StorageError. + Err(e) => Err(ApiError::ServerError(e.to_string())), + } +} diff --git a/remote_signer/client/src/config.rs b/remote_signer/client/src/config.rs new file mode 100644 index 0000000000..434f0030bc --- /dev/null +++ b/remote_signer/client/src/config.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use std::net::Ipv4Addr; + +/// HTTP REST API Configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// The IPv4 address the REST API HTTP server will listen on. + pub listen_address: Ipv4Addr, + /// The port the REST API HTTP server will listen on. + pub port: u16, +} + +impl Default for Config { + fn default() -> Self { + Config { + listen_address: Ipv4Addr::new(127, 0, 0, 1), + port: 9000, + } + } +} diff --git a/remote_signer/client/src/handler.rs b/remote_signer/client/src/handler.rs new file mode 100644 index 0000000000..ea5b64ba75 --- /dev/null +++ b/remote_signer/client/src/handler.rs @@ -0,0 +1,113 @@ +use crate::api_error::{ApiError, ApiResult}; +use crate::rest_api::Context; +use hyper::{Body, Request, Response, StatusCode}; +use serde::Serialize; +use std::sync::Arc; +use types::EthSpec; + +/// Provides a HTTP request handler with specific functionality. +pub struct Handler<E: EthSpec, S: Send + Sync> { + req: Request<()>, + body: Body, + ctx: Arc<Context<E, S>>, + allow_body: bool, +} + +impl<E: EthSpec, S: 'static + Send + Sync> Handler<E, S> { + /// Start handling a new request. + pub fn new(req: Request<Body>, ctx: Arc<Context<E, S>>) -> Result<Self, ApiError> { + let (req_parts, body) = req.into_parts(); + let req = Request::from_parts(req_parts, ()); + + Ok(Self { + req, + body, + ctx, + allow_body: false, + }) + } + + /// Return a simple static value. + /// + /// Does not use the blocking executor. + pub async fn static_value<V>(self, value: V) -> Result<HandledRequest<V>, ApiError> { + // Always check and disallow a body for a static value. + let _ = Self::get_body(self.body, false).await?; + + Ok(HandledRequest { value }) + } + + /// The default behaviour is to return an error if any body is supplied in the request. Calling + /// this function disables that error. + pub fn allow_body(mut self) -> Self { + self.allow_body = true; + self + } + + /// Spawns `func` on the blocking executor. + /// + /// This method is suitable for handling long-running or intensive tasks. + pub async fn in_blocking_task<F, V>(self, func: F) -> Result<HandledRequest<V>, ApiError> + where + V: Send + Sync + 'static, + F: Fn(Request<Vec<u8>>, Arc<Context<E, S>>) -> Result<V, ApiError> + Send + Sync + 'static, + { + let ctx = self.ctx; + let executor = ctx.executor.clone(); + let body = Self::get_body(self.body, self.allow_body).await?; + let (req_parts, _) = self.req.into_parts(); + let req = Request::from_parts(req_parts, body); + + let value = executor + .runtime_handle() + .spawn_blocking(move || func(req, ctx)) + .await + .map_err(|e| { + ApiError::ServerError(format!( + "Failed to get blocking join handle: {}", + e.to_string() + )) + })??; + + Ok(HandledRequest { value }) + } + + /// Downloads the bytes for `body`. + async fn get_body(body: Body, allow_body: bool) -> Result<Vec<u8>, ApiError> { + let bytes = hyper::body::to_bytes(body) + .await + .map_err(|e| ApiError::ServerError(format!("Unable to get request body: {:?}", e)))?; + + if !allow_body && !bytes[..].is_empty() { + Err(ApiError::BadRequest( + "The request body must be empty".to_string(), + )) + } else { + Ok(bytes.into_iter().collect()) + } + } +} + +/// A request that has been "handled" and now a result (`value`) needs to be serialized and +/// returned. +pub struct HandledRequest<V> { + value: V, +} + +impl<V: Serialize> HandledRequest<V> { + /// Suitable for items which only implement `serde`. + pub fn serde_encodings(self) -> ApiResult { + let body = Body::from(serde_json::to_string(&self.value).map_err(|e| { + ApiError::ServerError(format!( + "Unable to serialize response body as JSON: {:?}", + e + )) + })?); + + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .map_err(|e| ApiError::ServerError(format!("Failed to build response: {:?}", e))) + } +} diff --git a/remote_signer/client/src/lib.rs b/remote_signer/client/src/lib.rs new file mode 100644 index 0000000000..54aa7779cc --- /dev/null +++ b/remote_signer/client/src/lib.rs @@ -0,0 +1,58 @@ +pub mod api_error; +pub mod api_response; + +mod backend; +mod config; +mod handler; +mod rest_api; +mod router; +mod signing_root; +mod upcheck; + +use clap::ArgMatches; +use client_backend::Backend; +use config::Config; +use environment::RuntimeContext; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use types::EthSpec; + +pub struct Client { + listening_address: SocketAddr, +} + +impl Client { + pub async fn new<E: EthSpec>( + context: RuntimeContext<E>, + cli_args: &ArgMatches<'_>, + ) -> Result<Self, String> { + let log = context.executor.log(); + + let mut config = Config::default(); + + if let Some(address) = cli_args.value_of("listen-address") { + config.listen_address = address + .parse::<Ipv4Addr>() + .map_err(|_| "listen-address is not a valid IPv4 address.")?; + } + + if let Some(port) = cli_args.value_of("port") { + config.port = port + .parse::<u16>() + .map_err(|_| "port is not a valid u16.")?; + } + + let backend = Backend::new(cli_args, log)?; + + // It is useful to get the listening address if you have set up your port to be 0. + let listening_address = + rest_api::start_server(context.executor, config, backend, context.eth_spec_instance) + .map_err(|e| format!("Failed to start HTTP API: {:?}", e))?; + + Ok(Self { listening_address }) + } + + pub fn get_listening_address(&self) -> SocketAddr { + self.listening_address + } +} diff --git a/remote_signer/client/src/rest_api.rs b/remote_signer/client/src/rest_api.rs new file mode 100644 index 0000000000..46179b9107 --- /dev/null +++ b/remote_signer/client/src/rest_api.rs @@ -0,0 +1,91 @@ +use crate::config::Config; +use client_backend::{Backend, Storage}; +use futures::future::TryFutureExt; +use hyper::server::conn::AddrStream; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Request, Server}; +use slog::{info, warn}; +use std::net::SocketAddr; +use std::sync::Arc; +use task_executor::TaskExecutor; +use types::{ChainSpec, EthSpec}; + +pub struct Context<E: EthSpec, S: Send + Sync> { + pub config: Config, + pub executor: TaskExecutor, + pub log: slog::Logger, + pub backend: Backend<S>, + pub eth_spec_instance: E, + pub spec: ChainSpec, +} + +pub fn start_server<E: EthSpec, S: Storage>( + executor: TaskExecutor, + config: Config, + backend: Backend<S>, + eth_spec_instance: E, +) -> Result<SocketAddr, hyper::Error> { + let log = executor.log(); + + let context = Arc::new(Context { + executor: executor.clone(), + log: log.clone(), + config: config.clone(), + backend, + eth_spec_instance, + spec: E::default_spec(), + }); + + // Define the function that will build the request handler. + let make_service = make_service_fn(move |_socket: &AddrStream| { + let ctx = context.clone(); + + async move { + Ok::<_, hyper::Error>(service_fn(move |req: Request<Body>| { + crate::router::on_http_request(req, ctx.clone()) + })) + } + }); + + let bind_addr = (config.listen_address, config.port).into(); + let server = Server::bind(&bind_addr).serve(make_service); + + // Determine the address the server is actually listening on. + // + // This may be different to `bind_addr` if bind port was 0 (this allows the OS to choose a free + // port). + let actual_listen_addr = server.local_addr(); + + // Build a channel to kill the HTTP server. + let exit = executor.exit(); + let inner_log = log.clone(); + let server_exit = async move { + let _ = exit.await; + info!(inner_log, "HTTP service shutdown"); + }; + + // Configure the `hyper` server to gracefully shutdown when the shutdown channel is triggered. + let inner_log = log.clone(); + let server_future = server + .with_graceful_shutdown(async { + server_exit.await; + }) + .map_err(move |e| { + warn!( + inner_log, + "HTTP server failed to start, Unable to bind"; "address" => format!("{:?}", e) + ) + }) + .unwrap_or_else(|_| ()); + + info!( + log, + "HTTP API started"; + "address" => format!("{}", actual_listen_addr.ip()), + "port" => actual_listen_addr.port(), + ); + + executor.spawn_without_exit(server_future, "http"); + + Ok(actual_listen_addr) +} diff --git a/remote_signer/client/src/router.rs b/remote_signer/client/src/router.rs new file mode 100644 index 0000000000..2586d00dfd --- /dev/null +++ b/remote_signer/client/src/router.rs @@ -0,0 +1,101 @@ +use crate::api_error::ApiError; +use crate::backend::{get_keys, sign_message}; +use crate::handler::Handler; +use crate::rest_api::Context; +use crate::upcheck::upcheck; +use client_backend::Storage; +use hyper::{Body, Method, Request, Response}; +use slog::debug; +use std::sync::Arc; +use std::time::Instant; +use types::EthSpec; + +pub async fn on_http_request<E: EthSpec, S: Storage>( + req: Request<Body>, + ctx: Arc<Context<E, S>>, +) -> Result<Response<Body>, ApiError> { + let path = req.uri().path().to_string(); + let received_instant = Instant::now(); + let log = ctx.log.clone(); + + match route(req, ctx).await { + Ok(response) => { + debug!( + log, + "HTTP API request successful"; + "path" => path, + "duration_ms" => Instant::now().duration_since(received_instant).as_millis() + ); + Ok(response) + } + + Err(error) => { + debug!( + log, + "HTTP API request failure"; + "path" => path, + "duration_ms" => Instant::now().duration_since(received_instant).as_millis() + ); + Ok(error.into()) + } + } +} + +async fn route<E: EthSpec, S: Storage>( + req: Request<Body>, + ctx: Arc<Context<E, S>>, +) -> Result<Response<Body>, ApiError> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + let ctx = ctx.clone(); + let handler = Handler::new(req, ctx)?; + + match (method, path.as_ref()) { + (Method::GET, "/upcheck") => handler.static_value(upcheck()).await?.serde_encodings(), + + (Method::GET, "/keys") => handler.in_blocking_task(get_keys).await?.serde_encodings(), + + (Method::POST, _) => route_post(&path, handler).await, + + _ => Err(ApiError::NotFound( + "Request path and/or method not found.".to_string(), + )), + } +} + +/// Responds to all the POST requests. +/// +/// Should be deprecated once a better routing library is used, such as `warp` +async fn route_post<E: EthSpec, S: Storage>( + path: &str, + handler: Handler<E, S>, +) -> Result<Response<Body>, ApiError> { + let mut path_segments = path[1..].trim_end_matches('/').split('/'); + + match path_segments.next() { + Some("sign") => { + let path_segments_count = path_segments.clone().count(); + + if path_segments_count == 0 { + return Err(ApiError::BadRequest( + "Parameter public_key needed in route /sign/:public_key".to_string(), + )); + } + + if path_segments_count > 1 { + return Err(ApiError::BadRequest( + "Only one path segment is allowed after /sign".to_string(), + )); + } + + handler + .allow_body() + .in_blocking_task(sign_message) + .await? + .serde_encodings() + } + _ => Err(ApiError::NotFound( + "Request path and/or method not found.".to_string(), + )), + } +} diff --git a/remote_signer/client/src/signing_root.rs b/remote_signer/client/src/signing_root.rs new file mode 100644 index 0000000000..f92740bc80 --- /dev/null +++ b/remote_signer/client/src/signing_root.rs @@ -0,0 +1,78 @@ +use crate::api_error::ApiError; +use serde::Deserialize; +use serde_json::{from_value, Value}; + +use types::{ + AttestationData, BeaconBlock, ChainSpec, Domain, Epoch, EthSpec, Fork, Hash256, SignedRoot, +}; + +#[derive(Deserialize)] +pub struct SignMessageRequestBody { + /// BLS Signature domain. + /// Supporting `beacon_proposer`, `beacon_attester`, and `randao`. + /// As defined in + /// * https://github.com/ethereum/eth2.0-specs/blob/dev/specs/phase0/beacon-chain.md#domain-types + /// * in lowercase, omitting the `domain` prefix. + bls_domain: String, + + /// Supporting `block`, `attestation`, and `epoch`. + /// (In LH these are `BeaconBlock`, `AttestationData`, and `Epoch`). + /// As defined in + /// * https://github.com/ethereum/eth2.0-APIs/blob/master/types/block.yaml + /// * https://github.com/ethereum/eth2.0-APIs/blob/master/types/attestation.yaml + /// * https://github.com/ethereum/eth2.0-APIs/blob/master/types/misc.yaml + data: Value, + + /// A `Fork` object containing previous and current versions. + /// As defined in + /// * https://github.com/ethereum/eth2.0-APIs/blob/master/types/misc.yaml + fork: Fork, + + /// A `Hash256` for domain separation and chain versioning. + genesis_validators_root: Hash256, +} + +pub fn get_signing_root<E: EthSpec>( + req: &hyper::Request<std::vec::Vec<u8>>, + spec: ChainSpec, +) -> Result<Hash256, ApiError> { + let body: SignMessageRequestBody = serde_json::from_slice(req.body()).map_err(|e| { + ApiError::BadRequest(format!("Unable to parse body message from JSON: {:?}", e)) + })?; + + let get_domain = |epoch, bls_domain| { + spec.get_domain(epoch, bls_domain, &body.fork, body.genesis_validators_root) + }; + + match body.bls_domain.as_str() { + "beacon_proposer" => { + let block = from_value::<BeaconBlock<E>>(body.data.clone()).map_err(|e| { + ApiError::BadRequest(format!("Unable to parse block from JSON: {:?}", e)) + })?; + + Ok(block.signing_root(get_domain(block.epoch(), Domain::BeaconProposer))) + } + + "beacon_attester" => { + let attestation = from_value::<AttestationData>(body.data.clone()).map_err(|e| { + ApiError::BadRequest(format!("Unable to parse attestation from JSON: {:?}", e)) + })?; + + Ok(attestation + .signing_root(get_domain(attestation.target.epoch, Domain::BeaconAttester))) + } + + "randao" => { + let epoch = from_value::<Epoch>(body.data.clone()).map_err(|e| { + ApiError::BadRequest(format!("Unable to parse attestation from JSON: {:?}", e)) + })?; + + Ok(epoch.signing_root(get_domain(epoch, Domain::Randao))) + } + + s => Err(ApiError::BadRequest(format!( + "Unsupported bls_domain parameter: {}", + s + ))), + } +} diff --git a/remote_signer/client/src/upcheck.rs b/remote_signer/client/src/upcheck.rs new file mode 100644 index 0000000000..145c0e3037 --- /dev/null +++ b/remote_signer/client/src/upcheck.rs @@ -0,0 +1,7 @@ +use crate::api_response::UpcheckApiResponse; + +pub fn upcheck() -> UpcheckApiResponse { + UpcheckApiResponse { + status: "OK".to_string(), + } +} diff --git a/remote_signer/src/cli.rs b/remote_signer/src/cli.rs new file mode 100644 index 0000000000..091410473c --- /dev/null +++ b/remote_signer/src/cli.rs @@ -0,0 +1,37 @@ +use clap::{App, Arg}; + +pub fn cli_app<'a, 'b>() -> App<'a, 'b> { + // Parse the CLI parameters. + App::new("remote_signer") + .visible_alias("rs") + .author("Sigma Prime <contact@sigmaprime.io>") + .setting(clap::AppSettings::ColoredHelp) + .about( + "Simple HTTP BLS signer service. \ + This service is designed to be consumed by Ethereum 2.0 clients, \ + looking for a more secure avenue to store their BLS12-381 secret keys, \ + while running their validators in more permisive and/or scalable environments.", + ) + .arg( + Arg::with_name("storage-raw-dir") + .long("storage-raw-dir") + .value_name("DIR") + .help("Data directory for secret keys in raw files."), + ) + .arg( + Arg::with_name("listen-address") + .long("listen-address") + .value_name("ADDRESS") + .help("The address to listen for TCP connections.") + .default_value("0.0.0.0") + .takes_value(true), + ) + .arg( + Arg::with_name("port") + .long("port") + .value_name("PORT") + .help("The TCP port to listen on.") + .default_value("9000") + .takes_value(true), + ) +} diff --git a/remote_signer/src/lib.rs b/remote_signer/src/lib.rs new file mode 100644 index 0000000000..d37ef6069d --- /dev/null +++ b/remote_signer/src/lib.rs @@ -0,0 +1,34 @@ +mod cli; + +use clap::ArgMatches; +use client::Client; +use environment::Environment; +use slog::info; +use types::EthSpec; + +pub use cli::cli_app; + +pub fn run<E: EthSpec>( + environment: &mut Environment<E>, + matches: &ArgMatches, +) -> Result<(), String> { + let context = environment.core_context(); + let exit = context.executor.exit(); + + info!( + context.log(), + "Starting remote signer"; + ); + + let client = environment + .runtime() + .block_on(Client::new(context, matches)) + .map_err(|e| format!("Failed to init Rest API: {}", e))?; + + environment.runtime().spawn(async move { + exit.await; + drop(client); + }); + + Ok(()) +} diff --git a/remote_signer/tests/get_keys.rs b/remote_signer/tests/get_keys.rs new file mode 100644 index 0000000000..88d981a11b --- /dev/null +++ b/remote_signer/tests/get_keys.rs @@ -0,0 +1,84 @@ +mod get_keys { + use client::api_response::KeysApiResponse; + use helpers::*; + + fn assert_ok(resp: ApiTestResponse, expected_keys_len: usize) { + assert_eq!(resp.status, 200); + assert_eq!( + serde_json::from_value::<KeysApiResponse>(resp.json) + .unwrap() + .keys + .len(), + expected_keys_len + ); + } + + fn assert_error(resp: ApiTestResponse, http_status: u16, error_msg: &str) { + assert_eq!(resp.status, http_status); + assert_eq!(resp.json["error"], error_msg); + } + + #[test] + fn all_files_in_dir_are_public_keys() { + let (test_signer, tmp_dir) = set_up_api_test_signer_raw_dir(); + add_key_files(&tmp_dir); + + let url = format!("{}/keys", test_signer.address); + + let resp = http_get(&url); + assert_ok(resp, 3); + + test_signer.shutdown(); + } + + #[test] + fn some_files_in_dir_are_public_keys() { + let (test_signer, tmp_dir) = set_up_api_test_signer_raw_dir(); + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + + let url = format!("{}/keys", test_signer.address); + + let resp = http_get(&url); + assert_ok(resp, 3); + + test_signer.shutdown(); + } + + #[test] + fn no_files_in_dir_are_public_keys() { + let (test_signer, tmp_dir) = set_up_api_test_signer_raw_dir(); + add_sub_dirs(&tmp_dir); + add_non_key_files(&tmp_dir); + + let url = format!("{}/keys", test_signer.address); + + let resp = http_get(&url); + assert_error(resp, 404, "No keys found in storage."); + + test_signer.shutdown(); + } + + #[test] + fn directory_failure() { + let (test_signer, tmp_dir) = set_up_api_test_signer_raw_dir(); + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + + // Somebody tripped over a wire. + set_permissions(tmp_dir.path(), 0o40311); + + let url = format!("{}/keys", test_signer.address); + + let resp = http_get(&url); + + // Be able to delete the tempdir afterward, regardless of this test result. + set_permissions(tmp_dir.path(), 0o40755); + + assert_error(resp, 500, "Storage error: PermissionDenied"); + + test_signer.shutdown(); + } +} diff --git a/remote_signer/tests/sign.rs b/remote_signer/tests/sign.rs new file mode 100644 index 0000000000..d019c44614 --- /dev/null +++ b/remote_signer/tests/sign.rs @@ -0,0 +1,402 @@ +mod sign { + use helpers::*; + + #[test] + fn additional_field() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let test_block_body = get_test_block_body(0xc137).replace( + ",\"genesis_validators_root\":\"0x000000000000000000000000000000000000000000000000000000000000c137\"", + ",\"genesis_validators_root\":\"0x000000000000000000000000000000000000000000000000000000000000c137\", \"foo\":\"bar\"", + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_ok(response, HAPPY_PATH_BLOCK_SIGNATURE_C137); + + test_signer.shutdown(); + } + + #[test] + fn storage_error() { + let (test_signer, tmp_dir) = set_up_api_test_signer_to_sign_message(); + let test_block_body = get_test_block_body(0xc137); + set_permissions(tmp_dir.path(), 0o40311); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40311); + + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let response = http_post_custom_body(&url, &test_block_body); + set_permissions(tmp_dir.path(), 0o40755); + set_permissions(&tmp_dir.path().join(PUBLIC_KEY_1), 0o40755); + + assert_sign_error(response, 500, "Storage error: PermissionDenied"); + + test_signer.shutdown(); + } + + #[test] + fn no_public_key_in_path() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let test_block_body = get_test_block_body(0xc137); + + let testcase = |url: String| { + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error( + response, + 400, + "Parameter public_key needed in route /sign/:public_key", + ); + }; + + testcase(format!("{}/sign/", test_signer.address)); + testcase(format!("{}/sign", test_signer.address)); + testcase(format!("{}/sign//", test_signer.address)); + testcase(format!("{}/sign///", test_signer.address)); + testcase(format!("{}/sign/?'or 1 = 1 --", test_signer.address)); + + test_signer.shutdown(); + } + + #[test] + fn additional_path_segments() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let test_block_body = get_test_block_body(0xc137); + + let testcase = |url: String| { + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error( + response, + 400, + "Only one path segment is allowed after /sign", + ); + }; + + testcase(format!("{}/sign/this/receipt", test_signer.address)); + testcase(format!("{}/sign/this/receipt/please", test_signer.address)); + testcase(format!("{}/sign/this/receipt/please?", test_signer.address)); + testcase(format!( + "{}/sign//{}/valid/pk", + test_signer.address, PUBLIC_KEY_1 + )); + + test_signer.shutdown(); + } + + #[test] + fn invalid_public_key() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let test_block_body = get_test_block_body(0xc137); + + let testcase = |url: String, expected_err: &str| { + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + format!("{}/sign/{}", test_signer.address, "ScottBakula"), + "Invalid public key: ScottBakula", + ); + testcase( + format!("{}/sign/{}", test_signer.address, "deadbeef"), + "Invalid public key: deadbeef", + ); + testcase( + format!("{}/sign/{}", test_signer.address, SILLY_FILE_NAME_1), + &format!("Invalid public key: {}", SILLY_FILE_NAME_1), + ); + testcase( + format!("{}/sign/{}", test_signer.address, SILLY_FILE_NAME_1), + &format!("Invalid public key: {}", SILLY_FILE_NAME_1), + ); + testcase( + format!("{}/sign/0x{}", test_signer.address, PUBLIC_KEY_1), + &format!("Invalid public key: 0x{}", PUBLIC_KEY_1), + ); + testcase( + format!("{}/sign/{}55", test_signer.address, PUBLIC_KEY_1), + &format!("Invalid public key: {}55", PUBLIC_KEY_1), + ); + + test_signer.shutdown(); + } + + #[test] + fn key_not_found() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, ABSENT_PUBLIC_KEY); + let test_block_body = get_test_block_body(0xc137); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error( + response, + 404, + &format!("Key not found: {}", ABSENT_PUBLIC_KEY), + ); + + test_signer.shutdown(); + } + + #[test] + fn invalid_secret_key() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!( + "{}/sign/{}", + test_signer.address, PUBLIC_KEY_FOR_INVALID_SECRET_KEY + ); + let test_block_body = get_test_block_body(0xc137); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error( + response, + 500, + &format!( + "Invalid secret key: public_key: {}; Invalid hex character: W at index 0", + PUBLIC_KEY_FOR_INVALID_SECRET_KEY + ), + ); + + test_signer.shutdown(); + } + + #[test] + fn key_mismatch() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, MISMATCHED_PUBLIC_KEY); + let test_block_body = get_test_block_body(0xc137); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error( + response, + 500, + &format!("Key mismatch: {}", MISMATCHED_PUBLIC_KEY), + ); + + test_signer.shutdown(); + } + + #[test] + fn invalid_json() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |custom_body: &str, expected_err: &str| { + let response = http_post_custom_body(&url, custom_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "Trolololololo", + "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 1)", + ); + testcase( + "{\"bls_domain\"}", + "Unable to parse body message from JSON: Error(\"expected `:`\", line: 1, column: 14)", + ); + testcase( + "{\"bls_domain\":}", + "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 15)", + ); + + testcase( + "{\"bls_domain\":\"}", + "Unable to parse body message from JSON: Error(\"EOF while parsing a string\", line: 1, column: 16)", + ); + + test_signer.shutdown(); + } + + #[test] + fn invalid_field_bls_domain() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_block_body = get_test_block_body(0xc137).replace( + "\"bls_domain\":\"beacon_proposer\"", + &format!("\"bls_domain\":{}", json_patch), + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase("\"blah\"", "Unsupported bls_domain parameter: blah"); + testcase("\"domain\"", "Unsupported bls_domain parameter: domain"); + testcase("\"\"", "Unsupported bls_domain parameter: "); + testcase("", "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 15)"); + testcase("1", "Unable to parse body message from JSON: Error(\"invalid type: integer `1`, expected a string\", line: 1, column: 15)"); + testcase("true", "Unable to parse body message from JSON: Error(\"invalid type: boolean `true`, expected a string\", line: 1, column: 18)"); + testcase("{\"cats\":\"3\"}", "Unable to parse body message from JSON: Error(\"invalid type: map, expected a string\", line: 1, column: 14)"); + testcase("[\"a\"]", "Unable to parse body message from JSON: Error(\"invalid type: sequence, expected a string\", line: 1, column: 14)"); + + test_signer.shutdown(); + } + + #[test] + fn missing_field_bls_domain() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let test_block_body = + get_test_block_body(0xc137).replace("\"bls_domain\":\"beacon_proposer\",", ""); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, "Unable to parse body message from JSON: Error(\"missing field `bls_domain`\", line: 1, column: 237203)"); + + test_signer.shutdown(); + } + + #[test] + fn invalid_field_fork() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_block_body = get_test_block_body(0xc137).replace( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + json_patch, + ); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"fork\":{\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"missing field `previous_version`\", line: 1, column: 237106)", + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"missing field `current_version`\", line: 1, column: 237107)", + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",", + "Unable to parse body message from JSON: Error(\"missing field `epoch`\", line: 1, column: 237218)", + ); + testcase( + "\"fork\":{\"previous_version\":\"INVALID_VALUE\",\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"missing 0x prefix\", line: 1, column: 237094)", + ); + testcase( + "\"fork\":{\"previous_version\":\"0xINVALID_VALUE\",\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"invalid hex (OddLength)\", line: 1, column: 237096)", + ); + testcase( + "\"fork\":{\"previous_version\":\"0xINVALID_VALUE_\",\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"invalid hex (InvalidHexCharacter { c: \\\'I\\\', index: 0 })\", line: 1, column: 237097)", + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"INVALID_VALUE\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"missing 0x prefix\", line: 1, column: 237125)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0xINVALID_VALUE\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"invalid hex (OddLength)\", line: 1, column: 237127)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0xINVALID_VALUE_\",\"epoch\":\"1545\"},", + "Unable to parse body message from JSON: Error(\"invalid hex (InvalidHexCharacter { c: \\\'I\\\', index: 0 })\", line: 1, column: 237128)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":},", + "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 237132)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":\"zzz\"},", + "Unable to parse body message from JSON: Error(\"invalid digit found in string\", line: 1, column: 237136)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":true},", + "Unable to parse body message from JSON: Error(\"invalid type: boolean `true`, expected a quoted or unquoted integer\", line: 1, column: 237135)" + ); + testcase( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":[\"a\"]},", + "Unable to parse body message from JSON: Error(\"invalid type: sequence, expected a quoted or unquoted integer\", line: 1, column: 237132)" + ); + + test_signer.shutdown(); + } + + #[test] + fn missing_field_fork() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let test_block_body = get_test_block_body(0xc137).replace( + "\"fork\":{\"previous_version\":\"0x01010101\",\"current_version\":\"0x02020202\",\"epoch\":\"1545\"},", + "", + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, "Unable to parse body message from JSON: Error(\"missing field `fork`\", line: 1, column: 237147)"); + + test_signer.shutdown(); + } + + #[test] + fn missing_field_data() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let test_block_body = get_test_block_body(0xc137).replace("\"data\":", "\"not-data\":"); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, "Unable to parse body message from JSON: Error(\"missing field `data`\", line: 1, column: 237830)"); + + test_signer.shutdown(); + } + + #[test] + fn invalid_field_genesis_validators_root() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_block_body = get_test_block_body(0xc137).replace( + ",\"genesis_validators_root\":\"0x000000000000000000000000000000000000000000000000000000000000c137\"", + &format!(",\"genesis_validators_root\":{}", json_patch), + ); + + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase("\"0\"", "Unable to parse body message from JSON: Error(\"0x prefix is missing\", line: 1, column: 237168)"); + testcase("\"0x\"", "Unable to parse body message from JSON: Error(\"invalid length 0, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237169)"); + testcase("\"0xa\"", "Unable to parse body message from JSON: Error(\"invalid length 1, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237170)"); + testcase("\"deadbeef\"", "Unable to parse body message from JSON: Error(\"0x prefix is missing\", line: 1, column: 237175)"); + testcase("\"0xdeadbeefzz\"", "Unable to parse body message from JSON: Error(\"invalid length 10, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237179)"); + testcase("\"0xdeadbeef1\"", "Unable to parse body message from JSON: Error(\"invalid length 9, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237178)"); + testcase("", "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 237166)"); + testcase("1", "Unable to parse body message from JSON: Error(\"invalid type: integer `1`, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237166)"); + testcase("true", "Unable to parse body message from JSON: Error(\"invalid type: boolean `true`, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237169)"); + testcase("{\"cats\":\"3\"}", "Unable to parse body message from JSON: Error(\"invalid type: map, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237165)"); + testcase("[\"a\"]", "Unable to parse body message from JSON: Error(\"invalid type: sequence, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237165)"); + testcase( + "\"0x000000000000000000000000000000000000000000000000000000000000c1370\"", + "Unable to parse body message from JSON: Error(\"invalid length 65, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237234)", + ); + testcase( + "\"0x000000000000000000000000000000000000000000000000000000000000c13700\"", + "Unable to parse body message from JSON: Error(\"invalid length 66, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237235)", + ); + testcase( + "\"0x000000000000000000000000000000000000000000000000000000000000c1370000\"", + "Unable to parse body message from JSON: Error(\"invalid length 68, expected a 0x-prefixed hex string with length of 64\", line: 1, column: 237237)", + ); + + test_signer.shutdown(); + } + + #[test] + fn missing_field_genesis_validators_root() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let test_block_body = get_test_block_body(0xc137).replace( + ",\"genesis_validators_root\":\"0x000000000000000000000000000000000000000000000000000000000000c137\"", + "", + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, "Unable to parse body message from JSON: Error(\"missing field `genesis_validators_root`\", line: 1, column: 237139)"); + + test_signer.shutdown(); + } +} diff --git a/remote_signer/tests/sign_attestation.rs b/remote_signer/tests/sign_attestation.rs new file mode 100644 index 0000000000..11968b4bb9 --- /dev/null +++ b/remote_signer/tests/sign_attestation.rs @@ -0,0 +1,89 @@ +mod sign_attestation { + use helpers::*; + + #[test] + fn happy_path() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + let test_attestation_body = get_test_attestation_body(0xc137); + + let response = http_post_custom_body(&url, &test_attestation_body); + + assert_sign_ok(response, HAPPY_PATH_ATT_SIGNATURE_C137); + + test_signer.shutdown(); + } + + #[test] + fn domain_mismatch() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_attestation_body = get_test_attestation_body(0xc137).replace( + "\"bls_domain\":\"beacon_attester\"", + &format!("\"bls_domain\":{}", json_patch), + ); + let response = http_post_custom_body(&url, &test_attestation_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"beacon_proposer\"", + "Unable to parse block from JSON: Error(\"missing field `proposer_index`\", line: 0, column: 0)", + ); + testcase( + "\"randao\"", + "Unable to parse attestation from JSON: Error(\"invalid type: map, expected a quoted or unquoted integer\", line: 0, column: 0)" + ); + testcase("\"blah\"", "Unsupported bls_domain parameter: blah"); + + test_signer.shutdown(); + } + + #[test] + fn missing_or_invalid_fields_within_attestation_data() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_attestation_body = get_test_attestation_body(0xc137).replace( + "\"data\":{\"slot\":\"49463\",\"index\":\"49463\"", + json_patch, + ); + let response = http_post_custom_body(&url, &test_attestation_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"data\":{\"slot\":\"a\",\"index\":49463", + "Unable to parse attestation from JSON: Error(\"invalid digit found in string\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":\"\",\"index\":\"49463\"", + "Unable to parse attestation from JSON: Error(\"cannot parse integer from empty string\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":-1,\"index\":\"49463\"", + "Unable to parse attestation from JSON: Error(\"invalid type: integer `-1`, expected a quoted or unquoted integer\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":\"-1\",\"index\":\"49463\"", + "Unable to parse attestation from JSON: Error(\"invalid digit found in string\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"index\":\"49463\"", + "Unable to parse attestation from JSON: Error(\"missing field `slot`\", line: 0, column: 0)", + ); + testcase( + "\"data\":{\"slot\":\"49463\"", + "Unable to parse attestation from JSON: Error(\"missing field `index`\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":\"49463\",\"index\":\"\"", + "Unable to parse attestation from JSON: Error(\"cannot parse integer from empty string\", line: 0, column: 0)", + ); + + test_signer.shutdown(); + } +} diff --git a/remote_signer/tests/sign_block.rs b/remote_signer/tests/sign_block.rs new file mode 100644 index 0000000000..509557cad9 --- /dev/null +++ b/remote_signer/tests/sign_block.rs @@ -0,0 +1,81 @@ +mod sign_block { + use helpers::*; + + #[test] + fn happy_path() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + let test_block_body = get_test_block_body(0xc137); + + let response = http_post_custom_body(&url, &test_block_body); + + assert_sign_ok(response, HAPPY_PATH_BLOCK_SIGNATURE_C137); + + test_signer.shutdown(); + } + + #[test] + fn domain_mismatch() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_block_body = get_test_block_body(0xc137).replace( + "\"bls_domain\":\"beacon_proposer\"", + &format!("\"bls_domain\":{}", json_patch), + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"beacon_attester\"", + "Unable to parse attestation from JSON: Error(\"missing field `index`\", line: 0, column: 0)", + ); + testcase( + "\"randao\"", + "Unable to parse attestation from JSON: Error(\"invalid type: map, expected a quoted or unquoted integer\", line: 0, column: 0)" + ); + testcase("\"blah\"", "Unsupported bls_domain parameter: blah"); + + test_signer.shutdown(); + } + + #[test] + fn missing_or_invalid_fields_within_block_data() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_block_body = get_test_block_body(0xc137).replace( + "\"data\":{\"slot\":\"49463\",\"proposer_index\":\"0\"", + json_patch, + ); + let response = http_post_custom_body(&url, &test_block_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"data\":{\"slot\":\"\",\"proposer_index\":\"0\"", + "Unable to parse block from JSON: Error(\"cannot parse integer from empty string\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":\"-1\",\"proposer_index\":\"0\"", + "Unable to parse block from JSON: Error(\"invalid digit found in string\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"proposer_index\":\"0\"", + "Unable to parse block from JSON: Error(\"missing field `slot`\", line: 0, column: 0)", + ); + testcase( + "\"data\":{\"slot\":\"49463\"", + "Unable to parse block from JSON: Error(\"missing field `proposer_index`\", line: 0, column: 0)" + ); + testcase( + "\"data\":{\"slot\":\"49463\",\"proposer_index\":\"\"", + "Unable to parse block from JSON: Error(\"cannot parse integer from empty string\", line: 0, column: 0)", + ); + + test_signer.shutdown(); + } +} diff --git a/remote_signer/tests/sign_randao.rs b/remote_signer/tests/sign_randao.rs new file mode 100644 index 0000000000..e2d7a9cf60 --- /dev/null +++ b/remote_signer/tests/sign_randao.rs @@ -0,0 +1,83 @@ +mod sign_randao { + + use helpers::*; + + #[test] + fn happy_path() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + let test_randao_body = get_test_randao_body(0xc137); + + let response = http_post_custom_body(&url, &test_randao_body); + assert_sign_ok(response, HAPPY_PATH_RANDAO_SIGNATURE_C137); + + test_signer.shutdown(); + } + + #[test] + fn domain_mismatch() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_randao_body = get_test_randao_body(0xc137).replace( + "\"bls_domain\":\"randao\"", + &format!("\"bls_domain\":{}", json_patch), + ); + let response = http_post_custom_body(&url, &test_randao_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "\"beacon_proposer\"", + "Unable to parse block from JSON: Error(\"invalid type: string \\\"49463\\\", expected struct BeaconBlock\", line: 0, column: 0)" + ); + testcase( + "\"beacon_attester\"", + "Unable to parse attestation from JSON: Error(\"invalid type: string \\\"49463\\\", expected struct AttestationData\", line: 0, column: 0)" + ); + testcase("\"blah\"", "Unsupported bls_domain parameter: blah"); + + test_signer.shutdown(); + } + + #[test] + fn invalid_field_data() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_to_sign_message(); + let url = format!("{}/sign/{}", test_signer.address, PUBLIC_KEY_1); + + let testcase = |json_patch, expected_err| { + let test_randao_body = get_test_randao_body(0xc137) + .replace(",\"data\":\"49463\"", &format!(",\"data\":{}", json_patch)); + let response = http_post_custom_body(&url, &test_randao_body); + assert_sign_error(response, 400, expected_err); + }; + + testcase( + "", + "Unable to parse body message from JSON: Error(\"expected value\", line: 1, column: 31)" + ); + testcase( + "\"\"", + "Unable to parse attestation from JSON: Error(\"cannot parse integer from empty string\", line: 0, column: 0)" + ); + testcase( + "\"-1\"", + "Unable to parse attestation from JSON: Error(\"invalid digit found in string\", line: 0, column: 0)" + ); + testcase( + "true", + "Unable to parse attestation from JSON: Error(\"invalid type: boolean `true`, expected a quoted or unquoted integer\", line: 0, column: 0)" + ); + testcase( + "{\"cats\":\"3\"}", + "Unable to parse attestation from JSON: Error(\"invalid type: map, expected a quoted or unquoted integer\", line: 0, column: 0)", + ); + testcase( + "[\"a\"]", + "Unable to parse attestation from JSON: Error(\"invalid type: sequence, expected a quoted or unquoted integer\", line: 0, column: 0)" + ); + + test_signer.shutdown(); + } +} diff --git a/remote_signer/tests/upcheck.rs b/remote_signer/tests/upcheck.rs new file mode 100644 index 0000000000..4c69d21085 --- /dev/null +++ b/remote_signer/tests/upcheck.rs @@ -0,0 +1,16 @@ +mod upcheck { + use helpers::*; + + #[test] + fn happy_path() { + let (test_signer, _tmp_dir) = set_up_api_test_signer_raw_dir(); + + let url = format!("{}/upcheck", test_signer.address); + + let resp = http_get(&url); + assert_eq!(resp.status, 200); + assert_eq!(resp.json["status"], "OK"); + + test_signer.shutdown(); + } +} diff --git a/testing/remote_signer_test/Cargo.toml b/testing/remote_signer_test/Cargo.toml new file mode 100644 index 0000000000..70afa28b4c --- /dev/null +++ b/testing/remote_signer_test/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "remote_signer_test" +version = "0.2.0" +authors = ["Herman Junge <herman@sigmaprime.io>"] +edition = "2018" + +[dependencies] +clap = "2.33.3" +client = { path = "../../remote_signer/client", package = "remote_signer_client" } +environment = { path = "../../lighthouse/environment" } +reqwest = { version = "0.10.8", features = ["blocking", "json"] } +serde = { version = "1.0.116", features = ["derive"] } +serde_json = "1.0.58" +tempdir = "0.3.7" +types = { path = "../../consensus/types" } diff --git a/testing/remote_signer_test/src/constants.rs b/testing/remote_signer_test/src/constants.rs new file mode 100644 index 0000000000..3d2480c630 --- /dev/null +++ b/testing/remote_signer_test/src/constants.rs @@ -0,0 +1,98 @@ +// Legit BLS pairs. +pub const PUBLIC_KEY_1: &str = "b7354252aa5bce27ab9537fd0158515935f3c3861419e1b4b6c8219b5dbd15fcf907bddf275442f3e32f904f79807a2a"; +pub const SECRET_KEY_1: &str = "68081afeb7ad3e8d469f87010804c3e8d53ef77d393059a55132637206cc59ec"; + +pub const PUBLIC_KEY_1_BYTES: [u8; 48] = [ + 183, 53, 66, 82, 170, 91, 206, 39, 171, 149, 55, 253, 1, 88, 81, 89, 53, 243, 195, 134, 20, 25, + 225, 180, 182, 200, 33, 155, 93, 189, 21, 252, 249, 7, 189, 223, 39, 84, 66, 243, 227, 47, 144, + 79, 121, 128, 122, 42, +]; + +pub const SECRET_KEY_1_BYTES: [u8; 32] = [ + 104, 8, 26, 254, 183, 173, 62, 141, 70, 159, 135, 1, 8, 4, 195, 232, 213, 62, 247, 125, 57, 48, + 89, 165, 81, 50, 99, 114, 6, 204, 89, 236, +]; + +pub const PUBLIC_KEY_2: &str = "9324739760579527b4f8c34c5df42f9fd89f59fdbe8a01d58675769f60fec5da9b9c8d7a3203cf2217692e49e7b98d97"; +pub const SECRET_KEY_2: &str = "45b5e876e5e57b23af3e86c37d708626cf1dcca6a650091bba2ddb3e0b7304ae"; + +pub const PUBLIC_KEY_3: &str = "8244ac66a8bffa0ce0af04d69ed7ed009951061259173a7c7ae1f25c049f0fcbbf2fad67b6d2b276a697315be755dac5"; +pub const SECRET_KEY_3: &str = "1e52a4e54e89ccba813d5f902545749c356f6187341b4e765bf43ece401762f6"; + +// It is valid (from 0731e07e99a0b1c69f0de13ad65e5c374e72d0a997d43387ad70448485879ca1), +// But we are not uploading it. +pub const ABSENT_PUBLIC_KEY: &str = "827803e94e4b8d306735df9002465b310fabb39802341dc5c616a204e4e8dc7dbb6caa4733b5da54f8cdeec7788e7500"; + +// This is the public key of 0e5faaa97a63929cecb8597949ae148c0607f1b30bd057a7487efeb4c701fbf8. +pub const MISMATCHED_PUBLIC_KEY: &str = "83d40dfb1cbcf2a55c139faa3feec14bdae92dd485009ac8c5670d241f71c2ce064afa48dbaf091e16d0e4356038b948"; + +// The valid secret key is 3d703bd0dfdf2abb925b2d6bf1adf045ce8d93b8baff07e3313c5e150b043e89 +pub const PUBLIC_KEY_FOR_INVALID_SECRET_KEY: &str = "aac313c0bc04880c4e9f4b0b69a9f310b09b9325027666cc7f255f88c7f35b82a82b2aa004c9be655b5696fea67f7300"; +pub const INVALID_SECRET_KEY: &str = "WubbaLubbaDubDub"; + +// This is the public key of 34e62afe7c4402009a46bf8af574f9d6701c2cf72b3868eeeb59dfa6e7ff6bcf. +pub const SUB_DIR_NAME: &str = "aadbe2d5c0316dd3c9a522029f332cde578730e61d759685d7ad3bf1166c5f0bf094c3abc105384506f052e2b7a1bae0"; + +// Silly files with long names (96 chars) to fill your BLS raw file directory. +pub const SILLY_FILE_NAME_1: &str = + "IAmAdamPrinceofEterniaDefenderofthesecretsoftheCastleGrayskullThisisCringermyfearlessfriendFabul"; +pub const SILLY_CONTENT_1: &str = "HemanandtheMastersoftheUniverse"; + +pub const SILLY_FILE_NAME_2: &str = + "InthenearfutureDocTerrorandhiscyborgcompanionHackerunleashtheirforcestoconquerEarthOnlyoneforcec"; +pub const SILLY_CONTENT_2: &str = "Centurions"; + +pub const SILLY_FILE_NAME_3: &str = + "OurworldisinperilGaiathespiritoftheearthcannolongerstandtheterribledestructionplaguingourplanetS"; +pub const SILLY_CONTENT_3: &str = "CaptainPlanet"; + +// Taken from some random string. +pub const SIGNING_ROOT: &str = "b6bb8f3765f93f4f1e7c7348479289c9261399a3c6906685e320071a1a13955c"; + +pub const SIGNING_ROOT_BYTES: [u8; 32] = [ + 182, 187, 143, 55, 101, 249, 63, 79, 30, 124, 115, 72, 71, 146, 137, 201, 38, 19, 153, 163, + 198, 144, 102, 133, 227, 32, 7, 26, 26, 19, 149, 92, +]; + +// Expected signature for the message 0xb6bb8f3765f93f4f1e7c7348479289c9261399a3c6906685e320071a1a13955c +// using 68081afeb7ad3e8d469f87010804c3e8d53ef77d393059a55132637206cc59ec as secret key +pub const EXPECTED_SIGNATURE_1: &str = "0xb5d0c01cef3b028e2c5f357c2d4b886f8e374d09dd660cd7dd14680d4f956778808b4d3b2ab743e890fc1a77ae62c3c90d613561b23c6adaeb5b0e288832304fddc08c7415080be73e556e8862a1b4d0f6aa8084e34a901544d5bb6aeed3a612"; + +pub const EXPECTED_SIGNATURE_1_BYTES: [u8; 96] = [ + 181, 208, 192, 28, 239, 59, 2, 142, 44, 95, 53, 124, 45, 75, 136, 111, 142, 55, 77, 9, 221, + 102, 12, 215, 221, 20, 104, 13, 79, 149, 103, 120, 128, 139, 77, 59, 42, 183, 67, 232, 144, + 252, 26, 119, 174, 98, 195, 201, 13, 97, 53, 97, 178, 60, 106, 218, 235, 91, 14, 40, 136, 50, + 48, 79, 221, 192, 140, 116, 21, 8, 11, 231, 62, 85, 110, 136, 98, 161, 180, 208, 246, 170, 128, + 132, 227, 74, 144, 21, 68, 213, 187, 106, 238, 211, 166, 18, +]; + +// Expected signature for the message 0xb6bb8f3765f93f4f1e7c7348479289c9261399a3c6906685e320071a1a13955c +// using 45b5e876e5e57b23af3e86c37d708626cf1dcca6a650091bba2ddb3e0b7304ae as secret key +pub const EXPECTED_SIGNATURE_2: &str = "0xb6b63e3cecd0967d9f9b90e3ee113dfb21ecd3901dbc654ca69649ac5a0746758661306627f18bb6d7a6ea03ace069500ee79a28154c172dd71ffe4b711875e48b60466a90f3a4dcacdbc9b5f5434ad68c91e603fe1703324d83617f5270aead"; + +pub const EXPECTED_SIGNATURE_2_BYTES: [u8; 96] = [ + 182, 182, 62, 60, 236, 208, 150, 125, 159, 155, 144, 227, 238, 17, 61, 251, 33, 236, 211, 144, + 29, 188, 101, 76, 166, 150, 73, 172, 90, 7, 70, 117, 134, 97, 48, 102, 39, 241, 139, 182, 215, + 166, 234, 3, 172, 224, 105, 80, 14, 231, 154, 40, 21, 76, 23, 45, 215, 31, 254, 75, 113, 24, + 117, 228, 139, 96, 70, 106, 144, 243, 164, 220, 172, 219, 201, 181, 245, 67, 74, 214, 140, 145, + 230, 3, 254, 23, 3, 50, 77, 131, 97, 127, 82, 112, 174, 173, +]; + +// Expected signature for the message 0xb6bb8f3765f93f4f1e7c7348479289c9261399a3c6906685e320071a1a13955c +// using 1e52a4e54e89ccba813d5f902545749c356f6187341b4e765bf43ece401762f6 as secret key +pub const EXPECTED_SIGNATURE_3: &str = "0x874f7d6d4174df1088ab40bd9a3c808554c55d6de1dffcacc7ef56c3ca22e20b52a23dd5bb6568a123b59df0bacef3de14d4c197a2fb2a5868a18c4b11f6d7957673d9a302bf6812b1d5df9b264504f682b43dfbcf4f9130cb5ebb9b8e3737de"; + +pub const EXPECTED_SIGNATURE_3_BYTES: [u8; 96] = [ + 135, 79, 125, 109, 65, 116, 223, 16, 136, 171, 64, 189, 154, 60, 128, 133, 84, 197, 93, 109, + 225, 223, 252, 172, 199, 239, 86, 195, 202, 34, 226, 11, 82, 162, 61, 213, 187, 101, 104, 161, + 35, 181, 157, 240, 186, 206, 243, 222, 20, 212, 193, 151, 162, 251, 42, 88, 104, 161, 140, 75, + 17, 246, 215, 149, 118, 115, 217, 163, 2, 191, 104, 18, 177, 213, 223, 155, 38, 69, 4, 246, + 130, 180, 61, 251, 207, 79, 145, 48, 203, 94, 187, 155, 142, 55, 55, 222, +]; + +// These HAPPY_PATH constants were obtained running "sanity check" tests. i.e. Usign the non-remote way for producing the signature. +pub const HAPPY_PATH_BLOCK_SIGNATURE_C137: &str = "0x87c2a5bbd71d6277802e8bf5319b84c9fbb7441c4ce56dc39e721ba4371e7521a04a36c166c5cba37e8c645e91cc31fc02884bf44fdeb51c52173e642934fd3f3e8f72f53c2ec7da284630dc86e49da75cb2578761403ed24c3a8e4bccf33e4c"; + +pub const HAPPY_PATH_ATT_SIGNATURE_C137: &str = "0xada13507d81feb5a5057565f45abed9248be56a463efa944598090bdcdd61c3fa51bb5ef34f845100efe0c14dc0c0fa20d068a7ea4f14c3e9b43aa1c44f14cb73371e48338a90622b0bee4c3a988b726d3ad87ea8a111115a0d6e1e908c421d8"; + +pub const HAPPY_PATH_RANDAO_SIGNATURE_C137: &str = "0x8c5c88491486d0c4572e4043fd42b3f774778c6f9c44464b61272c9667d18f3ca894ae08344fcd7f6dd8b6954572b90a10ce7457367cecaa9f6ef7bf105aa2e79ae7e9568317d933ac2a8e45fb06f3edfc3f6f5881ca96c8eed0c2a83fa9bc2d"; diff --git a/testing/remote_signer_test/src/lib.rs b/testing/remote_signer_test/src/lib.rs new file mode 100644 index 0000000000..e141d15892 --- /dev/null +++ b/testing/remote_signer_test/src/lib.rs @@ -0,0 +1,320 @@ +mod constants; +mod objects; + +use clap::{App, Arg, ArgMatches}; +use client::api_response::SignatureApiResponse; +use client::Client; +pub use constants::*; +use environment::{Environment, EnvironmentBuilder}; +pub use objects::*; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::fs::{create_dir, File}; +use std::io::Write; +use std::net::IpAddr::{V4, V6}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tempdir::TempDir; +use types::{AttestationData, BeaconBlock, Epoch, EthSpec, Fork, Hash256, MainnetEthSpec}; + +pub type E = MainnetEthSpec; + +pub struct ApiTestSigner<E: EthSpec> { + pub address: String, + environment: Environment<E>, +} + +pub struct ApiTestResponse { + pub status: u16, + pub json: Value, +} + +impl ApiTestSigner<MainnetEthSpec> { + pub fn new(arg_vec: Vec<&str>) -> Self { + let matches = set_matches(arg_vec); + let mut environment = get_environment(false); + let runtime_context = environment.core_context(); + + let client = environment + .runtime() + .block_on(Client::new(runtime_context, &matches)) + .map_err(|e| format!("Failed to init Rest API: {}", e)) + .unwrap(); + + let address = get_address(&client); + + Self { + address, + environment, + } + } + + pub fn shutdown(mut self) { + self.environment.fire_signal() + } +} + +pub fn set_matches(arg_vec: Vec<&str>) -> ArgMatches<'static> { + let matches = App::new("BLS_Remote_Signer") + .arg( + Arg::with_name("storage-raw-dir") + .long("storage-raw-dir") + .value_name("DIR"), + ) + .arg( + Arg::with_name("port") + .long("port") + .value_name("PORT") + .default_value("9000") + .takes_value(true), + ); + + matches.get_matches_from(arg_vec) +} + +pub fn get_environment(is_log_active: bool) -> Environment<E> { + let environment_builder = EnvironmentBuilder::mainnet(); + + let builder = if is_log_active { + environment_builder.async_logger("info", None).unwrap() + } else { + environment_builder.null_logger().unwrap() + }; + + builder + .multi_threaded_tokio_runtime() + .unwrap() + .build() + .unwrap() +} + +pub fn set_up_api_test_signer_raw_dir() -> (ApiTestSigner<E>, TempDir) { + let tmp_dir = TempDir::new("bls-remote-signer-test").unwrap(); + let arg_vec = vec![ + "this_test", + "--port", + "0", + "--storage-raw-dir", + tmp_dir.path().to_str().unwrap(), + ]; + let test_signer = ApiTestSigner::new(arg_vec); + + (test_signer, tmp_dir) +} + +pub fn set_up_api_test_signer_to_sign_message() -> (ApiTestSigner<E>, TempDir) { + let (test_signer, tmp_dir) = set_up_api_test_signer_raw_dir(); + add_sub_dirs(&tmp_dir); + add_key_files(&tmp_dir); + add_non_key_files(&tmp_dir); + add_mismatched_key_file(&tmp_dir); + add_invalid_secret_key_file(&tmp_dir); + + (test_signer, tmp_dir) +} + +pub fn get_address(client: &Client) -> String { + let listening_address = client.get_listening_address(); + let ip = match listening_address.ip() { + V4(ip) => ip.to_string(), + V6(ip) => ip.to_string(), + }; + + format!("http://{}:{}", ip, listening_address.port()) +} + +pub fn set_permissions(path: &Path, perm_octal: u32) { + let metadata = fs::metadata(path).unwrap(); + let mut permissions = metadata.permissions(); + permissions.set_mode(perm_octal); + fs::set_permissions(path, permissions).unwrap(); +} + +pub fn add_key_files(tmp_dir: &TempDir) { + let pairs = vec![ + (PUBLIC_KEY_1, SECRET_KEY_1), + (PUBLIC_KEY_2, SECRET_KEY_2), + (PUBLIC_KEY_3, SECRET_KEY_3), + ]; + + add_files(tmp_dir, pairs); +} + +pub fn add_mismatched_key_file(tmp_dir: &TempDir) { + let pairs = vec![(MISMATCHED_PUBLIC_KEY, SECRET_KEY_1)]; + + add_files(tmp_dir, pairs); +} + +pub fn add_invalid_secret_key_file(tmp_dir: &TempDir) { + let pairs = vec![(PUBLIC_KEY_FOR_INVALID_SECRET_KEY, INVALID_SECRET_KEY)]; + + add_files(tmp_dir, pairs); +} + +pub fn add_non_key_files(tmp_dir: &TempDir) { + let pairs = vec![ + (SILLY_FILE_NAME_1, SILLY_CONTENT_1), + (SILLY_FILE_NAME_2, SILLY_CONTENT_2), + (SILLY_FILE_NAME_3, SILLY_CONTENT_3), + ]; + + add_files(tmp_dir, pairs); +} + +fn add_files(tmp_dir: &TempDir, pairs: Vec<(&str, &str)>) { + for pair in pairs { + let file_path = tmp_dir.path().join(pair.0); + let mut tmp_file = File::create(file_path).unwrap(); + writeln!(tmp_file, "{}", pair.1).unwrap(); + } +} + +pub fn add_sub_dirs(tmp_dir: &TempDir) { + let random_sub_dir_path = tmp_dir.path().join("random_sub_dir_name"); + create_dir(random_sub_dir_path).unwrap(); + + let another_sub_dir_path = tmp_dir.path().join(SUB_DIR_NAME); + create_dir(another_sub_dir_path).unwrap(); +} + +pub fn http_get(url: &str) -> ApiTestResponse { + let response = reqwest::blocking::get(url).unwrap(); + + ApiTestResponse { + status: response.status().as_u16(), + json: serde_json::from_str(&response.text().unwrap()).unwrap(), + } +} + +pub fn http_post(url: &str, hashmap: HashMap<&str, &str>) -> ApiTestResponse { + let response = reqwest::blocking::Client::new() + .post(url) + .json(&hashmap) + .send() + .unwrap(); + + ApiTestResponse { + status: response.status().as_u16(), + json: serde_json::from_str(&response.text().unwrap()).unwrap(), + } +} + +pub fn http_post_custom_body(url: &str, body: &str) -> ApiTestResponse { + let response = reqwest::blocking::Client::new() + .post(url) + .body(body.to_string()) + .send() + .unwrap(); + + ApiTestResponse { + status: response.status().as_u16(), + json: serde_json::from_str(&response.text().unwrap()).unwrap(), + } +} + +#[derive(Serialize)] +pub struct BlockRequestBody<E: EthSpec> { + bls_domain: String, + data: BeaconBlock<E>, + fork: Fork, + genesis_validators_root: Hash256, +} + +pub fn get_test_block_body(seed: u64) -> String { + let block: BeaconBlock<E> = get_block(seed); + let epoch = block.epoch(); + + let fork = Fork { + previous_version: [1; 4], + current_version: [2; 4], + epoch, + }; + + let genesis_validators_root = Hash256::from_low_u64_be(seed); + + let block_request_body = BlockRequestBody { + bls_domain: "beacon_proposer".to_string(), + data: block, + fork, + genesis_validators_root, + }; + + serde_json::to_string(&block_request_body).unwrap() +} + +#[derive(Serialize)] +pub struct AttestationRequestBody { + bls_domain: String, + data: AttestationData, + fork: Fork, + genesis_validators_root: Hash256, +} + +pub fn get_test_attestation_body(seed: u64) -> String { + let attestation = get_attestation::<E>(seed); + let epoch = attestation.target.epoch; + + let fork = Fork { + previous_version: [1; 4], + current_version: [2; 4], + epoch, + }; + + let genesis_validators_root = Hash256::from_low_u64_be(seed); + + let attestation_request_body = AttestationRequestBody { + bls_domain: "beacon_attester".to_string(), + data: attestation, + fork, + genesis_validators_root, + }; + + serde_json::to_string(&attestation_request_body).unwrap() +} + +#[derive(Serialize)] +pub struct RandaoRequestBody { + bls_domain: String, + data: Epoch, + fork: Fork, + genesis_validators_root: Hash256, +} + +pub fn get_test_randao_body(seed: u64) -> String { + let epoch = Epoch::new(seed); + + let fork = Fork { + previous_version: [1; 4], + current_version: [2; 4], + epoch, + }; + + let genesis_validators_root = Hash256::from_low_u64_be(seed); + + let randao_request_body = RandaoRequestBody { + bls_domain: "randao".to_string(), + data: epoch, + fork, + genesis_validators_root, + }; + + serde_json::to_string(&randao_request_body).unwrap() +} + +pub fn assert_sign_ok(resp: ApiTestResponse, expected_signature: &str) { + assert_eq!(resp.status, 200); + assert_eq!( + serde_json::from_value::<SignatureApiResponse>(resp.json) + .unwrap() + .signature, + expected_signature + ); +} + +pub fn assert_sign_error(resp: ApiTestResponse, http_status: u16, error_msg: &str) { + assert_eq!(resp.status, http_status); + assert_eq!(resp.json["error"].as_str().unwrap(), error_msg); +} diff --git a/testing/remote_signer_test/src/objects.rs b/testing/remote_signer_test/src/objects.rs new file mode 100644 index 0000000000..62dfdb2140 --- /dev/null +++ b/testing/remote_signer_test/src/objects.rs @@ -0,0 +1,127 @@ +use types::{ + AggregateSignature, Attestation, AttestationData, AttesterSlashing, BeaconBlock, + BeaconBlockHeader, BitList, Checkpoint, Deposit, DepositData, Epoch, EthSpec, FixedVector, + Hash256, IndexedAttestation, ProposerSlashing, PublicKeyBytes, Signature, SignatureBytes, + SignedBeaconBlockHeader, SignedVoluntaryExit, Slot, Unsigned, VariableList, VoluntaryExit, +}; + +/// We spice up some of the values, based on a given `seed` parameter. +pub fn get_block<E: EthSpec>(seed: u64) -> BeaconBlock<E> { + let spec = &mut E::default_spec(); + spec.genesis_slot = Slot::new(seed); + + let header = BeaconBlockHeader { + slot: Slot::new(seed), + proposer_index: 0, + parent_root: Hash256::from_low_u64_be(222 * seed), + state_root: Hash256::from_low_u64_be(333 * seed), + body_root: Hash256::from_low_u64_be(444 * seed), + }; + + let signed_header = SignedBeaconBlockHeader { + message: header, + signature: Signature::empty(), + }; + let indexed_attestation: IndexedAttestation<E> = IndexedAttestation { + attesting_indices: VariableList::new(vec![ + 0 as u64; + E::MaxValidatorsPerCommittee::to_usize() + ]) + .unwrap(), + data: AttestationData::default(), + signature: AggregateSignature::empty(), + }; + + let deposit_data = DepositData { + pubkey: PublicKeyBytes::empty(), + withdrawal_credentials: Hash256::from_low_u64_be(555 * seed), + amount: 0, + signature: SignatureBytes::empty(), + }; + let proposer_slashing = ProposerSlashing { + signed_header_1: signed_header.clone(), + signed_header_2: signed_header, + }; + + let attester_slashing = AttesterSlashing { + attestation_1: indexed_attestation.clone(), + attestation_2: indexed_attestation, + }; + + let attestation: Attestation<E> = Attestation { + aggregation_bits: BitList::with_capacity(E::MaxValidatorsPerCommittee::to_usize()).unwrap(), + data: AttestationData::default(), + signature: AggregateSignature::empty(), + }; + + let deposit = Deposit { + proof: FixedVector::from_elem(Hash256::from_low_u64_be(666 * seed)), + data: deposit_data, + }; + + let voluntary_exit = VoluntaryExit { + epoch: Epoch::new(1), + validator_index: 1, + }; + + let signed_voluntary_exit = SignedVoluntaryExit { + message: voluntary_exit, + signature: Signature::empty(), + }; + + let mut block: BeaconBlock<E> = BeaconBlock::empty(spec); + for _ in 0..E::MaxProposerSlashings::to_usize() { + block + .body + .proposer_slashings + .push(proposer_slashing.clone()) + .unwrap(); + } + for _ in 0..E::MaxDeposits::to_usize() { + block.body.deposits.push(deposit.clone()).unwrap(); + } + for _ in 0..E::MaxVoluntaryExits::to_usize() { + block + .body + .voluntary_exits + .push(signed_voluntary_exit.clone()) + .unwrap(); + } + for _ in 0..E::MaxAttesterSlashings::to_usize() { + block + .body + .attester_slashings + .push(attester_slashing.clone()) + .unwrap(); + } + + for _ in 0..E::MaxAttestations::to_usize() { + block.body.attestations.push(attestation.clone()).unwrap(); + } + block +} + +pub fn get_attestation<E: EthSpec>(seed: u64) -> AttestationData { + let slot = Slot::from(seed); + let epoch = slot.epoch(E::slots_per_epoch()); + + let build_checkpoint = |epoch_u64: u64| -> Checkpoint { + Checkpoint { + epoch: Epoch::new(epoch_u64), + root: Hash256::from_low_u64_be(333 * seed), + } + }; + + let source = build_checkpoint(epoch.as_u64().saturating_sub(2)); + let target = build_checkpoint(epoch.as_u64()); + + let index = 0xc137u64; + + AttestationData { + slot, + index, + beacon_block_root: Hash256::from_low_u64_be(666 * seed), + source, + target, + } +}