diff --git a/config.example.toml b/config.example.toml index 1d53d522..7b6a4a11 100644 --- a/config.example.toml +++ b/config.example.toml @@ -158,12 +158,17 @@ key_path = "./keys.example.json" # For teku, it's the path to the directory where all `.txt` files are located. # For lodestar, it's the path to the file containing the decryption password. # secrets_path = "" -# Configuration for how the Signer module should store proxy delegations. Currently one type of store is supported: +# Configuration for how the Signer module should store proxy delegations. Supported types of store are: # - File: store keys and delegations from a plain text file (unsafe, use only for testing purposes) +# - ERC2335: store keys and delegations safely using ERC-2335 style keystores. More details can be found in the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration#proxy-keys-store) # OPTIONAL, if missing proxies are lost on restart [signer.local.store] # File: path to the keys file proxy_dir = "./proxies" +# ERC2335: path to the keys directory +# keys_path = "./tests/data/proxy/keys" +# ERC2335: path to the secrets directory +# secrets_path = "./tests/data/proxy/secrets" # Commit-Boost can optionally run "modules" which extend the capabilities of the sidecar. # Currently, two types of modules are supported: diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index f946cd19..1f135f66 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -9,18 +9,20 @@ use cb_common::{ CommitBoostConfig, LogsSettings, ModuleKind, SignerConfig, BUILDER_PORT_ENV, BUILDER_URLS_ENV, CHAIN_SPEC_ENV, CONFIG_DEFAULT, CONFIG_ENV, JWTS_ENV, LOGS_DIR_DEFAULT, LOGS_DIR_ENV, METRICS_PORT_ENV, MODULE_ID_ENV, MODULE_JWT_ENV, PBS_ENDPOINT_ENV, - PBS_MODULE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, SIGNER_DEFAULT, SIGNER_DIR_KEYS_DEFAULT, - SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, SIGNER_DIR_SECRETS_ENV, SIGNER_KEYS_ENV, - SIGNER_MODULE_NAME, SIGNER_PORT_ENV, SIGNER_URL_ENV, + PBS_MODULE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, PROXY_DIR_KEYS_DEFAULT, + PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, PROXY_DIR_SECRETS_ENV, SIGNER_DEFAULT, + SIGNER_DIR_KEYS_DEFAULT, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, + SIGNER_DIR_SECRETS_ENV, SIGNER_KEYS_ENV, SIGNER_MODULE_NAME, SIGNER_PORT_ENV, + SIGNER_URL_ENV, }, signer::{ProxyStore, SignerLoader}, types::ModuleId, utils::random_jwt, }; use docker_compose_types::{ - Compose, ComposeVolume, DependsOnOptions, EnvFile, Environment, Labels, LoggingParameters, - MapOrEmpty, NetworkSettings, Networks, Ports, Service, Services, SingleValue, TopLevelVolumes, - Volumes, + Compose, ComposeVolume, DependsCondition, DependsOnOptions, EnvFile, Environment, Healthcheck, + HealthcheckTest, Labels, LoggingParameters, MapOrEmpty, NetworkSettings, Networks, Ports, + Service, Services, SingleValue, TopLevelVolumes, Volumes, }; use eyre::Result; use indexmap::IndexMap; @@ -151,6 +153,12 @@ pub fn handle_docker_init(config_path: String, output_dir: String) -> Result<()> module_volumes.extend(chain_spec_volume.clone()); module_volumes.extend(get_log_volume(&cb_config.logs, &module.id)); + // depends_on + let mut module_dependencies = IndexMap::new(); + module_dependencies.insert("cb_signer".into(), DependsCondition { + condition: "service_healthy".into(), + }); + Service { container_name: Some(module_cid.clone()), image: Some(module.docker_image), @@ -160,7 +168,7 @@ pub fn handle_docker_init(config_path: String, output_dir: String) -> Result<()> depends_on: if let Some(SignerConfig::Remote { .. }) = &cb_config.signer { DependsOnOptions::Simple(vec![]) } else { - DependsOnOptions::Simple(vec!["cb_signer".to_owned()]) + DependsOnOptions::Conditional(module_dependencies) }, env_file, ..Service::default() @@ -367,6 +375,23 @@ pub fn handle_docker_init(config_path: String, output_dir: String) -> Result<()> let (k, v) = get_env_val(PROXY_DIR_ENV, PROXY_DIR_DEFAULT); signer_envs.insert(k, v); } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + volumes.push(Volumes::Simple(format!( + "{}:{}:rw", + keys_path.display(), + PROXY_DIR_KEYS_DEFAULT + ))); + let (k, v) = get_env_val(PROXY_DIR_KEYS_ENV, PROXY_DIR_KEYS_DEFAULT); + signer_envs.insert(k, v); + + volumes.push(Volumes::Simple(format!( + "{}:{}:rw", + secrets_path.display(), + PROXY_DIR_SECRETS_DEFAULT + ))); + let (k, v) = get_env_val(PROXY_DIR_SECRETS_ENV, PROXY_DIR_SECRETS_DEFAULT); + signer_envs.insert(k, v); + } } } @@ -384,6 +409,16 @@ pub fn handle_docker_init(config_path: String, output_dir: String) -> Result<()> networks: Networks::Simple(signer_networks), volumes, environment: Environment::KvPair(signer_envs), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Single(format!( + "curl -f http://localhost:{signer_port}/status" + ))), + interval: Some("5s".into()), + timeout: Some("5s".into()), + retries: 5, + start_period: Some("0s".into()), + disable: false, + }), ..Service::default() }; diff --git a/crates/common/src/commit/constants.rs b/crates/common/src/commit/constants.rs index 0bcfc174..3335833a 100644 --- a/crates/common/src/commit/constants.rs +++ b/crates/common/src/commit/constants.rs @@ -1,3 +1,4 @@ pub const GET_PUBKEYS_PATH: &str = "/signer/v1/get_pubkeys"; pub const REQUEST_SIGNATURE_PATH: &str = "/signer/v1/request_signature"; pub const GENERATE_PROXY_KEY_PATH: &str = "/signer/v1/generate_proxy_key"; +pub const STATUS_PATH: &str = "/status"; diff --git a/crates/common/src/commit/request.rs b/crates/common/src/commit/request.rs index f3b056ee..b45861f0 100644 --- a/crates/common/src/commit/request.rs +++ b/crates/common/src/commit/request.rs @@ -1,4 +1,7 @@ -use std::fmt::{self, Debug, Display, LowerHex}; +use std::{ + fmt::{self, Debug, Display, LowerHex}, + str::FromStr, +}; use alloy::rpc::types::beacon::BlsSignature; use derive_more::derive::From; @@ -133,6 +136,27 @@ pub enum EncryptionScheme { Ecdsa, } +impl Display for EncryptionScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EncryptionScheme::Bls => write!(f, "bls"), + EncryptionScheme::Ecdsa => write!(f, "ecdsa"), + } + } +} + +impl FromStr for EncryptionScheme { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "bls" => Ok(EncryptionScheme::Bls), + "ecdsa" => Ok(EncryptionScheme::Ecdsa), + _ => Err(format!("Unknown scheme: {s}")), + } + } +} + // TODO(David): This struct shouldn't be visible to module authors #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateProxyRequest { diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index 8dc577d2..03d990e8 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -47,9 +47,15 @@ pub const SIGNER_DIR_KEYS_DEFAULT: &str = "/keys"; /// Path to `secrets` folder pub const SIGNER_DIR_SECRETS_ENV: &str = "CB_SIGNER_LOADER_SECRETS_DIR"; pub const SIGNER_DIR_SECRETS_DEFAULT: &str = "/secrets"; -/// Path to store proxies +/// Path to store proxies with plaintext keys (testing only) pub const PROXY_DIR_ENV: &str = "CB_PROXY_STORE_DIR"; pub const PROXY_DIR_DEFAULT: &str = "/proxies"; +/// Path to store proxy keys +pub const PROXY_DIR_KEYS_ENV: &str = "CB_PROXY_KEYS_DIR"; +pub const PROXY_DIR_KEYS_DEFAULT: &str = "/proxy_keys"; +/// Path to store proxy secrets +pub const PROXY_DIR_SECRETS_ENV: &str = "CB_PROXY_SECRETS_DIR"; +pub const PROXY_DIR_SECRETS_DEFAULT: &str = "/proxy_secrets"; ///////////////////////// MODULES ///////////////////////// diff --git a/crates/common/src/signer/loader.rs b/crates/common/src/signer/loader.rs index ebdcc13a..1ed0b068 100644 --- a/crates/common/src/signer/loader.rs +++ b/crates/common/src/signer/loader.rs @@ -10,14 +10,14 @@ use aes::{ Aes128, }; use alloy::{primitives::hex::FromHex, rpc::types::beacon::BlsPublicKey}; -use eth2_keystore::Keystore; +use eth2_keystore::{json_keystore::JsonKeystore, Keystore}; use eyre::{eyre, Context, OptionExt}; use pbkdf2::{hmac, pbkdf2}; use serde::{de, Deserialize, Deserializer, Serialize}; use tracing::warn; use unicode_normalization::UnicodeNormalization; -use super::{PrysmDecryptedKeystore, PrysmKeystore}; +use super::{BlsSigner, EcdsaSigner, PrysmDecryptedKeystore, PrysmKeystore}; use crate::{ config::{load_env_var, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_ENV, SIGNER_KEYS_ENV}, signer::ConsensusSigner, @@ -56,8 +56,10 @@ impl SignerLoader { pub fn load_from_env(self) -> eyre::Result> { Ok(match self { - SignerLoader::File { .. } => { - let path = load_env_var(SIGNER_KEYS_ENV)?; + SignerLoader::File { key_path } => { + let path = load_env_var(SIGNER_KEYS_ENV).unwrap_or( + key_path.to_str().ok_or_eyre("Missing signer key path")?.to_string(), + ); let file = std::fs::read_to_string(path) .unwrap_or_else(|_| panic!("Unable to find keys file")); @@ -288,6 +290,20 @@ fn load_one(ks_path: String, pw_path: String) -> eyre::Result { ConsensusSigner::new_from_bytes(key.sk.serialize().as_bytes()) } +pub fn load_bls_signer(keys_path: PathBuf, secrets_path: PathBuf) -> eyre::Result { + load_one(keys_path.to_string_lossy().to_string(), secrets_path.to_string_lossy().to_string()) +} + +pub fn load_ecdsa_signer(keys_path: PathBuf, secrets_path: PathBuf) -> eyre::Result { + let key_file = std::fs::File::open(keys_path.to_string_lossy().to_string())?; + let key_reader = std::io::BufReader::new(key_file); + let keystore: JsonKeystore = serde_json::from_reader(key_reader)?; + let password = std::fs::read(secrets_path)?; + let decrypted_password = eth2_keystore::decrypt(&password, &keystore.crypto).unwrap(); + + EcdsaSigner::new_from_bytes(decrypted_password.as_bytes()) +} + #[cfg(test)] mod tests { diff --git a/crates/common/src/signer/store.rs b/crates/common/src/signer/store.rs index 5a6e9303..878fdb67 100644 --- a/crates/common/src/signer/store.rs +++ b/crates/common/src/signer/store.rs @@ -2,15 +2,32 @@ use std::{ collections::HashMap, fs::{create_dir_all, read_to_string}, io::Write, - path::PathBuf, + path::{Path, PathBuf}, + str::FromStr, }; -use alloy::primitives::Bytes; +use alloy::{ + hex, + primitives::{Bytes, FixedBytes}, + rpc::types::beacon::constants::BLS_SIGNATURE_BYTES_LEN, +}; +use eth2_keystore::{ + default_kdf, + json_keystore::{ + Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, JsonKeystore, KdfModule, + Sha256Checksum, + }, + Uuid, IV_SIZE, SALT_SIZE, +}; +use eyre::OptionExt; +use rand::Rng; use serde::{Deserialize, Serialize}; +use tracing::warn; +use super::{load_bls_signer, load_ecdsa_signer}; use crate::{ - commit::request::{PublicKey, SignedProxyDelegation}, - config::{load_env_var, PROXY_DIR_ENV}, + commit::request::{EncryptionScheme, ProxyDelegation, PublicKey, SignedProxyDelegation}, + config::{load_env_var, PROXY_DIR_ENV, PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_ENV}, signer::{ BlsProxySigner, BlsPublicKey, BlsSigner, EcdsaProxySigner, EcdsaPublicKey, EcdsaSigner, ProxySigners, @@ -28,16 +45,37 @@ struct KeyAndDelegation { #[serde(untagged)] pub enum ProxyStore { /// Stores private keys in plaintext to a file, do not use in prod - File { proxy_dir: PathBuf }, + File { + proxy_dir: PathBuf, + }, + ERC2335 { + keys_path: PathBuf, + secrets_path: PathBuf, + }, } impl ProxyStore { pub fn init_from_env(self) -> eyre::Result { Ok(match self { - ProxyStore::File { .. } => { - let path = load_env_var(PROXY_DIR_ENV)?; + ProxyStore::File { proxy_dir } => { + let path = load_env_var(PROXY_DIR_ENV) + .unwrap_or(proxy_dir.to_str().ok_or_eyre("Missing proxy dir")?.to_string()); ProxyStore::File { proxy_dir: PathBuf::from(path) } } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + let keys_path = if let Ok(path) = load_env_var(PROXY_DIR_KEYS_ENV) { + PathBuf::from_str(&path)? + } else { + keys_path + }; + let secrets_path = if let Ok(path) = load_env_var(PROXY_DIR_SECRETS_ENV) { + PathBuf::from_str(&path)? + } else { + secrets_path + }; + + ProxyStore::ERC2335 { keys_path, secrets_path } + } }) } @@ -63,6 +101,16 @@ impl ProxyStore { let mut file = std::fs::File::create(file_path)?; file.write_all(content.as_ref())?; } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + store_erc2335_key( + module_id, + proxy.delegation, + proxy.secret().to_vec(), + keys_path, + secrets_path, + EncryptionScheme::Bls, + )?; + } } Ok(()) @@ -90,6 +138,16 @@ impl ProxyStore { let mut file = std::fs::File::create(file_path)?; file.write_all(content.as_ref())?; } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + store_erc2335_key( + module_id, + proxy.delegation, + proxy.secret(), + keys_path, + secrets_path, + EncryptionScheme::Ecdsa, + )?; + } } Ok(()) @@ -183,6 +241,422 @@ impl ProxyStore { Ok((proxy_signers, bls_map, ecdsa_map)) } + ProxyStore::ERC2335 { keys_path, secrets_path } => { + let mut proxy_signers = ProxySigners::default(); + let mut bls_map: HashMap> = HashMap::new(); + let mut ecdsa_map: HashMap> = HashMap::new(); + + for entry in std::fs::read_dir(keys_path)? { + let entry = entry?; + let consensus_key_path = entry.path(); + let consensus_pubkey = + match FixedBytes::from_str(&entry.file_name().to_string_lossy()) { + Ok(bytes) => BlsPublicKey::from(bytes), + Err(e) => { + warn!("Failed to parse consensus pubkey: {e}"); + continue; + } + }; + + if !consensus_key_path.is_dir() { + warn!("{consensus_key_path:?} is not a directory"); + continue; + } + + for entry in std::fs::read_dir(&consensus_key_path)? { + let entry = entry?; + let module_path = entry.path(); + let module_id = entry.file_name().to_string_lossy().to_string(); + + if !module_path.is_dir() { + warn!("{module_path:?} is not a directory"); + continue; + } + + let bls_path = module_path.join("bls"); + if let Ok(bls_keys) = std::fs::read_dir(&bls_path) { + for entry in bls_keys { + let entry = entry?; + let path = entry.path(); + + if !path.is_file() || + !path.extension().is_some_and(|ext| ext == "json") + { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + let name = name.trim_end_matches(".json"); + + let signer = load_bls_signer( + path, + secrets_path + .join(consensus_pubkey.to_string()) + .join(&module_id) + .join("bls") + .join(name), + ) + .map_err(|e| eyre::eyre!("Error loading BLS signer: {e}"))?; + + let delegation_signature = match std::fs::read_to_string( + bls_path.join(format!("{name}.sig")), + ) { + Ok(sig) => { + FixedBytes::::from_str(&sig)? + } + Err(e) => { + warn!("Failed to read delegation signature: {e}"); + continue; + } + }; + + let proxy_signer = BlsProxySigner { + signer: signer.clone(), + delegation: SignedProxyDelegation { + message: ProxyDelegation { + delegator: consensus_pubkey, + proxy: signer.pubkey(), + }, + signature: delegation_signature, + }, + }; + + proxy_signers.bls_signers.insert(signer.pubkey(), proxy_signer); + bls_map + .entry(ModuleId(module_id.clone())) + .or_default() + .push(signer.pubkey()); + } + } + + let ecdsa_path = module_path.join("ecdsa"); + if let Ok(ecdsa_keys) = std::fs::read_dir(&ecdsa_path) { + for entry in ecdsa_keys { + let entry = entry?; + let path = entry.path(); + + if !path.is_file() || + !path.extension().is_some_and(|ext| ext == "json") + { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + let name = name.trim_end_matches(".json"); + + let signer = load_ecdsa_signer( + path, + secrets_path + .join(format!("{consensus_pubkey:#x}")) + .join(&module_id) + .join("ecdsa") + .join(name), + )?; + let delegation_signature = match std::fs::read_to_string( + ecdsa_path.join(format!("{name}.sig")), + ) { + Ok(sig) => { + FixedBytes::::from_str(&sig)? + } + Err(e) => { + warn!("Failed to read delegation signature: {e}",); + continue; + } + }; + + let proxy_signer = EcdsaProxySigner { + signer: signer.clone(), + delegation: SignedProxyDelegation { + message: ProxyDelegation { + delegator: consensus_pubkey, + proxy: signer.pubkey(), + }, + signature: delegation_signature, + }, + }; + + proxy_signers.ecdsa_signers.insert(signer.pubkey(), proxy_signer); + ecdsa_map + .entry(ModuleId(module_id.clone())) + .or_default() + .push(signer.pubkey()); + } + } + } + } + Ok((proxy_signers, bls_map, ecdsa_map)) + } } } } + +fn store_erc2335_key( + module_id: &ModuleId, + delegation: SignedProxyDelegation, + secret: Vec, + keys_path: &Path, + secrets_path: &Path, + scheme: EncryptionScheme, +) -> eyre::Result<()> { + let proxy_pubkey = delegation.message.proxy; + + let password_bytes: [u8; 32] = rand::thread_rng().gen(); + let password = hex::encode(password_bytes); + + let pass_path = secrets_path + .join(delegation.message.delegator.to_string()) + .join(&module_id.0) + .join(scheme.to_string()); + std::fs::create_dir_all(&pass_path)?; + let pass_path = pass_path.join(proxy_pubkey.to_string()); + let mut pass_file = std::fs::File::create(&pass_path)?; + pass_file.write_all(password.as_bytes())?; + + let sig_path = keys_path + .join(delegation.message.delegator.to_string()) + .join(&module_id.0) + .join(scheme.to_string()); + std::fs::create_dir_all(&sig_path)?; + let sig_path = sig_path.join(format!("{}.sig", proxy_pubkey)); + + let mut sig_file = std::fs::File::create(sig_path)?; + sig_file.write_all(delegation.signature.to_string().as_bytes())?; + + let salt: [u8; SALT_SIZE] = rand::thread_rng().gen(); + let iv: [u8; IV_SIZE] = rand::thread_rng().gen(); + let kdf = default_kdf(salt.to_vec()); + let cipher = Cipher::Aes128Ctr(Aes128Ctr { iv: iv.to_vec().into() }); + let (cipher_text, checksum) = + eth2_keystore::encrypt(&secret, password.as_bytes(), &kdf, &cipher) + .map_err(|_| eyre::eyre!("Error encrypting key"))?; + + let keystore = JsonKeystore { + crypto: Crypto { + kdf: KdfModule { + function: kdf.function(), + params: kdf, + message: eth2_keystore::json_keystore::EmptyString, + }, + checksum: ChecksumModule { + function: Sha256Checksum::function(), + params: eth2_keystore::json_keystore::EmptyMap, + message: checksum.to_vec().into(), + }, + cipher: CipherModule { + function: cipher.function(), + params: cipher, + message: cipher_text.into(), + }, + }, + uuid: Uuid::new_v4(), + path: None, + pubkey: format!("{:x}", delegation.message.proxy), + version: eth2_keystore::json_keystore::Version::V4, + description: Some(delegation.message.proxy.to_string()), + name: None, + }; + + let json_path = keys_path + .join(delegation.message.delegator.to_string()) + .join(&module_id.0) + .join(scheme.to_string()); + std::fs::create_dir_all(&json_path)?; + let json_path = json_path.join(format!("{}.json", proxy_pubkey)); + let mut json_file = std::fs::File::create(&json_path)?; + json_file.write_all(serde_json::to_string(&keystore)?.as_bytes())?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use hex::FromHex; + use tree_hash::TreeHash; + + use super::*; + use crate::{ + commit::request::{ProxyDelegationBls, SignedProxyDelegationBls}, + signer::ConsensusSigner, + types::Chain, + }; + + #[tokio::test] + async fn test_erc2335_storage_format() { + let tmp_path = std::env::temp_dir().join("test_erc2335_storage_format"); + let keys_path = tmp_path.join("keys"); + let secrets_path = tmp_path.join("secrets"); + let store = ProxyStore::ERC2335 { + keys_path: keys_path.clone(), + secrets_path: secrets_path.clone(), + }; + + let module_id = ModuleId("TEST_MODULE".to_string()); + let consensus_signer = ConsensusSigner::new_from_bytes(&hex!( + "0088e364a5396a81b50febbdc8784663fb9089b5e67cbdc173991a00c587673f" + )) + .unwrap(); + let proxy_signer = BlsSigner::new_from_bytes(&hex!( + "13000f8b3d7747e7754022720d33d5b506490429f3d593162f00e254f97d2940" + )) + .unwrap(); + + let message = ProxyDelegationBls { + delegator: consensus_signer.pubkey(), + proxy: proxy_signer.pubkey(), + }; + let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0).await; + let delegation = SignedProxyDelegationBls { signature, message }; + let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; + + store.store_proxy_bls(&module_id, &proxy_signer).unwrap(); + + let json_path = keys_path + .join(consensus_signer.pubkey().to_string()) + .join("TEST_MODULE") + .join("bls") + .join(format!("{}.json", proxy_signer.pubkey().to_string())); + let sig_path = keys_path + .join(consensus_signer.pubkey().to_string()) + .join("TEST_MODULE") + .join("bls") + .join(format!("{}.sig", proxy_signer.pubkey().to_string())); + let pass_path = secrets_path + .join(consensus_signer.pubkey().to_string()) + .join("TEST_MODULE") + .join("bls") + .join(proxy_signer.pubkey().to_string()); + + assert!(json_path.exists()); + assert!(sig_path.exists()); + assert!(pass_path.exists()); + + let keystore: JsonKeystore = + serde_json::de::from_str(&std::fs::read_to_string(json_path).unwrap()).unwrap(); + + assert_eq!(keystore.pubkey, proxy_signer.pubkey().to_string().trim_start_matches("0x")); + + let sig = FixedBytes::from_hex(std::fs::read_to_string(sig_path).unwrap()); + assert!(sig.is_ok()); + assert_eq!(sig.unwrap(), signature); + } + + #[test] + fn test_erc2335_load() { + let keys_path = Path::new("../../tests/data/proxy/keys").to_path_buf(); + let secrets_path = Path::new("../../tests/data/proxy/secrets").to_path_buf(); + let store = ProxyStore::ERC2335 { + keys_path: keys_path.clone(), + secrets_path: secrets_path.clone(), + }; + + let (proxy_signers, bls_keys, ecdsa_keys) = store.load_proxies().unwrap(); + assert_eq!(bls_keys.len(), 1); + assert_eq!(ecdsa_keys.len(), 0); + assert_eq!(proxy_signers.bls_signers.len(), 1); + assert_eq!(proxy_signers.ecdsa_signers.len(), 0); + + let proxy_key = BlsPublicKey::from( + FixedBytes::from_hex( + "a77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba" + ).unwrap() + ); + let consensus_key = BlsPublicKey::from( + FixedBytes::from_hex( + "ac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118" + ).unwrap() + ); + + let proxy_signer = proxy_signers.bls_signers.get(&proxy_key); + + assert!(proxy_signer.is_some()); + let proxy_signer = proxy_signer.unwrap(); + + assert_eq!( + proxy_signer.delegation.signature, + FixedBytes::from_hex( + std::fs::read_to_string( + keys_path + .join(consensus_key.to_string()) + .join("TEST_MODULE") + .join("bls") + .join(format!("{proxy_key}.sig")) + ) + .unwrap() + ) + .unwrap() + ); + assert_eq!(proxy_signer.delegation.message.delegator, consensus_key); + assert_eq!(proxy_signer.delegation.message.proxy, proxy_key); + + assert!(bls_keys + .get(&ModuleId("TEST_MODULE".into())) + .is_some_and(|keys| keys.contains(&proxy_key))); + } + + #[tokio::test] + async fn test_erc2335_store_and_load() { + let tmp_path = std::env::temp_dir().join("test_erc2335_store_and_load"); + let keys_path = tmp_path.join("keys"); + let secrets_path = tmp_path.join("secrets"); + let store = ProxyStore::ERC2335 { + keys_path: keys_path.clone(), + secrets_path: secrets_path.clone(), + }; + + let module_id = ModuleId("TEST_MODULE".to_string()); + let consensus_signer = ConsensusSigner::new_from_bytes(&hex!( + "0088e364a5396a81b50febbdc8784663fb9089b5e67cbdc173991a00c587673f" + )) + .unwrap(); + let proxy_signer = BlsSigner::new_from_bytes(&hex!( + "13000f8b3d7747e7754022720d33d5b506490429f3d593162f00e254f97d2940" + )) + .unwrap(); + + let message = ProxyDelegationBls { + delegator: consensus_signer.pubkey(), + proxy: proxy_signer.pubkey(), + }; + let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0).await; + let delegation = SignedProxyDelegationBls { signature, message }; + let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; + + store.store_proxy_bls(&module_id, &proxy_signer).unwrap(); + + let load_result = store.load_proxies(); + assert!(load_result.is_ok()); + + let (proxy_signers, bls_keys, ecdsa_keys) = load_result.unwrap(); + + assert_eq!(bls_keys.len(), 1); + assert_eq!(ecdsa_keys.len(), 0); + assert_eq!(proxy_signers.bls_signers.len(), 1); + assert_eq!(proxy_signers.ecdsa_signers.len(), 0); + + let loaded_proxy_signer = proxy_signers.bls_signers.get(&proxy_signer.pubkey()); + + assert!(loaded_proxy_signer.is_some()); + let loaded_proxy_signer = loaded_proxy_signer.unwrap(); + + assert_eq!( + loaded_proxy_signer.delegation.signature, + FixedBytes::from_hex( + std::fs::read_to_string( + keys_path + .join(consensus_signer.pubkey().to_string()) + .join("TEST_MODULE") + .join("bls") + .join(format!("{}.sig", proxy_signer.pubkey().to_string())) + ) + .unwrap() + ) + .unwrap() + ); + assert_eq!(loaded_proxy_signer.delegation.message.delegator, consensus_signer.pubkey()); + assert_eq!(loaded_proxy_signer.delegation.message.proxy, proxy_signer.pubkey()); + + assert!(bls_keys + .get(&ModuleId("TEST_MODULE".into())) + .is_some_and(|keys| keys.contains(&proxy_signer.pubkey()))); + } +} diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 6e27e590..14703a09 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -12,7 +12,9 @@ use axum_extra::TypedHeader; use bimap::BiHashMap; use cb_common::{ commit::{ - constants::{GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, REQUEST_SIGNATURE_PATH}, + constants::{ + GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, REQUEST_SIGNATURE_PATH, STATUS_PATH, + }, request::{ EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, SignConsensusRequest, SignProxyRequest, SignRequest, @@ -77,11 +79,14 @@ impl SigningService { .route(GENERATE_PROXY_KEY_PATH, post(handle_generate_proxy)) .with_state(state.clone()) .route_layer(middleware::from_fn_with_state(state.clone(), jwt_auth)); + let status_router = axum::Router::new().route(STATUS_PATH, get(handle_status)); let address = SocketAddr::from(([0, 0, 0, 0], config.server_port)); let listener = TcpListener::bind(address).await?; - axum::serve(listener, app).await.wrap_err("signer server exited") + axum::serve(listener, axum::Router::new().merge(app).merge(status_router)) + .await + .wrap_err("signer server exited") } } @@ -104,6 +109,11 @@ async fn jwt_auth( Ok(next.run(req).await) } +/// Status endpoint for the Signer API +async fn handle_status() -> Result { + Ok((StatusCode::OK, "OK")) +} + /// Implements get_pubkeys from the Signer API async fn handle_get_pubkeys( Extension(module_id): Extension, diff --git a/docker/signer.Dockerfile b/docker/signer.Dockerfile index d26d679b..3f267806 100644 --- a/docker/signer.Dockerfile +++ b/docker/signer.Dockerfile @@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y \ ca-certificates \ libssl3 \ libssl-dev \ + curl \ && apt-get clean autoclean \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 12b3a29b..638c7859 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -142,6 +142,89 @@ We currently support Lighthouse, Prysm, Teku and Lodestar's keystores so it's ea ::: +### Proxy keys store + +Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer module and authorized by the validator key. Each module have their own proxy keys, that can be BLS or ECDSA. + +To persist proxy keys across restarts, you must enable the proxy store in the config file. There are 2 options for this: + +
+ File + + The keys are stored in plain text in a file. This method is unsafe and should only be used for testing. + + #### File structure + + ``` + + └── + └── bls + ├── + └── + ``` + + #### Configuration + + ```toml + [signer.local.store] + proxy_dir = "path/to/proxy_dir" + ``` + + Where each `` file contains the following: + ```json + { + "secret": "0x...", + "delegation": { + "message": { + "delegator": "0x...", + "proxy": "0x..." + }, + "signature": "0x..." + } + } + ``` +
+ +
+ ERC2335 + + The keys are stored in a ERC-2335 style keystore, among with a password. This way, you can safely share the keys directory so without the password they are useless. + + #### File structure + + ``` + ├── + │ └── + │ └── + │ ├── bls/ + │ │ ├── .json + │ │ ├── .sig + │ │ ├── .json + │ │ └── .sig + │ └── ecdsa/ + │ ├── .json + │ └── .sig + └── + └── + └── + ├── bls/ + │ ├── + │ └── + └── ecdsa + └── + ``` + + #### Configuration + + ```toml + [signer.local.store] + keys_path = "path/to/keys" + secrets_path = "path/to/secrets" + ``` + + Where the `.json` files contain ERC-2335 keystore, the `.sig` files contain the signature of the delegation, and `` files contain the password to decrypt the keystores. +
+ ### Remote signer You might choose to use an external service to sign the transactions. For now, we support Web3Signer but we're working on adding support for additional signers. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index 20f9b8ad..2c93d3f2 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -32,6 +32,7 @@ For loading keys we currently support: - `CB_SIGNER_LOADER_FORMAT`, `CB_SIGNER_LOADER_KEYS_DIR` and `CB_SIGNER_LOADER_SECRETS_DIR`: paths to the `keys` and `secrets` directories or files (ERC-2335 style keystores, see [Signer config](../configuration/#signer-module) for more info) For storing proxy keys we currently support: - `CB_PROXY_STORE_DIR`: directory where proxy keys and delegations will be saved in plaintext (for testing purposes only) + - `CB_PROXY_KEYS_DIR` and `CB_PROXY_SECRETS_DIR`: paths to the `keys` and `secrets` directories (ERC-2335 style keystores, see [Signer config](../configuration#proxy-keys-store) for more info) ### Modules diff --git a/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.json b/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.json new file mode 100644 index 00000000..e55d22ab --- /dev/null +++ b/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.json @@ -0,0 +1 @@ +{"crypto":{"kdf":{"function":"scrypt","params":{"dklen":32,"n":262144,"r":8,"p":1,"salt":"c84961e82805391c0f761cf342c1e6293dab474d388179f4fdea8386310d3920"},"message":""},"checksum":{"function":"sha256","params":{},"message":"4a6ed334d558abeb81ea04893eeed79214eaec476d6225bacccbc7ffbde95843"},"cipher":{"function":"aes-128-ctr","params":{"iv":"bff99639dd8ad6e3339177bad87dcac4"},"message":"e9bca9829d688baa09e65ddecadedd1cb6b49c024a9fff98630817cf835aa9bb"}},"uuid":"38fcc27a-da59-4604-8858-cf3d58d06acc","path":null,"pubkey":"a77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba","version":4,"description":"0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba","name":null} \ No newline at end of file diff --git a/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.sig b/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.sig new file mode 100644 index 00000000..2ac675c2 --- /dev/null +++ b/tests/data/proxy/keys/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba.sig @@ -0,0 +1 @@ +0xb2e44e777cc68b50b9d19cbded2b2b6a0a5c428e3c341b5ade22f90e67679116511855b94e26ae930d1350628933994713f4fd48d1d70715a99d875a564c88e229aa9bb2d89e9f60b725c97300659bd0fc7bc1e2e599f12625b81ef63890f857 \ No newline at end of file diff --git a/tests/data/proxy/secrets/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba b/tests/data/proxy/secrets/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba new file mode 100644 index 00000000..6d8f0bc3 --- /dev/null +++ b/tests/data/proxy/secrets/0xac5e059177afc33263e95d0be0690138b9a1d79a6e19018086a0362e0c30a50bf9e05a08cb44785724d0b2718c5c7118/TEST_MODULE/bls/0xa77084280678d9f1efe4ef47a3d62af27872ce82db19a35ee012c4fd5478e6b1123b8869032ba18b2383e8873294f0ba @@ -0,0 +1 @@ +4ecdc703bdc0b4957876643fbba74f20f5cf7e4435b852fcd9b2d0c2b977a854 \ No newline at end of file