diff --git a/Cargo.lock b/Cargo.lock index dac2c8de..fe345ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,6 +1488,7 @@ dependencies = [ "cipher 0.4.4", "ctr 0.9.2", "derive_more 2.0.1", + "docker-image", "eth2_keystore", "ethereum_serde_utils", "ethereum_ssz 0.8.3", @@ -1589,9 +1590,11 @@ dependencies = [ "axum 0.8.1", "cb-common", "cb-pbs", + "cb-signer", "eyre", "reqwest", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", @@ -2158,6 +2161,16 @@ dependencies = [ "serde_yaml", ] +[[package]] +name = "docker-image" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ed901b8f2157bafce6e96f39217f7b1a4af32d84266d251ed7c22ce001f0b" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "doctest-file" version = "1.0.0" @@ -4863,9 +4876,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "getrandom 0.3.1", diff --git a/Cargo.toml b/Cargo.toml index 283caf0d..996da14d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ color-eyre = "0.6.3" ctr = "0.9.2" derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into"] } docker-compose-types = "0.16.0" +docker-image = "0.2.1" eth2_keystore = { git = "https://github.com/sigp/lighthouse", rev = "8d058e4040b765a96aa4968f4167af7571292be2" } ethereum_serde_utils = "0.7.0" ethereum_ssz = "0.8" @@ -57,6 +58,7 @@ serde_json = "1.0.117" serde_yaml = "0.9.33" sha2 = "0.10.8" ssz_types = "0.10" +tempfile = "3.20.0" thiserror = "2.0.12" tokio = { version = "1.37.0", features = ["full"] } toml = "0.8.13" diff --git a/config.example.toml b/config.example.toml index d32dfbf9..15d80599 100644 --- a/config.example.toml +++ b/config.example.toml @@ -144,16 +144,22 @@ url = "http://0xa119589bb33ef52acbb8116832bec2b58fca590fe5c85eac5d3230b44d5bc09f # - Dirk: a remote Dirk instance # - Local: a local Signer module # More details on the docs (https://commit-boost.github.io/commit-boost-client/get_started/configuration/#signer-module) -# [signer] +[signer] # Docker image to use for the Signer module. # OPTIONAL, DEFAULT: ghcr.io/commit-boost/signer:latest -# docker_image = "ghcr.io/commit-boost/signer:latest" +docker_image = "ghcr.io/commit-boost/signer:latest" # Host to bind the Signer API server to # OPTIONAL, DEFAULT: 127.0.0.1 host = "127.0.0.1" # Port to listen for Signer API calls on # OPTIONAL, DEFAULT: 20000 port = 20000 +# Number of JWT authentication attempts a client can fail before blocking that client temporarily from Signer access +# OPTIONAL, DEFAULT: 3 +jwt_auth_fail_limit = 3 +# How long to block a client from Signer access, in seconds, if it failed JWT authentication too many times +# OPTIONAL, DEFAULT: 300 +jwt_auth_fail_timeout_seconds = 300 # For Remote signer: # [signer.remote] @@ -233,6 +239,8 @@ proxy_dir = "./proxies" [[modules]] # Unique ID of the module id = "DA_COMMIT" +# Unique hash that the Signer service will combine with the incoming data in signing requests to generate a signature specific to this module +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" # Type of the module. Supported values: commit, events type = "commit" # Docker image of the module diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index 7f418e97..706e863e 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -15,10 +15,10 @@ use cb_common::{ 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_ENDPOINT_ENV, SIGNER_KEYS_ENV, SIGNER_MODULE_NAME, - SIGNER_URL_ENV, + SIGNER_PORT_DEFAULT, SIGNER_URL_ENV, }, pbs::{BUILDER_API_PATH, GET_STATUS_PATH}, - signer::{ProxyStore, SignerLoader, DEFAULT_SIGNER_PORT}, + signer::{ProxyStore, SignerLoader}, types::ModuleId, utils::random_jwt_secret, }; @@ -73,7 +73,7 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re let mut targets = Vec::new(); // address for signer API communication - let signer_port = cb_config.signer.as_ref().map(|s| s.port).unwrap_or(DEFAULT_SIGNER_PORT); + let signer_port = cb_config.signer.as_ref().map(|s| s.port).unwrap_or(SIGNER_PORT_DEFAULT); let signer_server = if let Some(SignerConfig { inner: SignerType::Remote { url }, .. }) = &cb_config.signer { url.to_string() diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index df78b046..c3955d4a 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -16,6 +16,7 @@ blst.workspace = true cipher.workspace = true ctr.workspace = true derive_more.workspace = true +docker-image.workspace = true eth2_keystore.workspace = true ethereum_serde_utils.workspace = true ethereum_ssz.workspace = true diff --git a/crates/common/src/commit/request.rs b/crates/common/src/commit/request.rs index b8843234..d9286868 100644 --- a/crates/common/src/commit/request.rs +++ b/crates/common/src/commit/request.rs @@ -57,6 +57,7 @@ impl SignedProxyDelegation { &self.message.delegator, &self.message, &self.signature, + None, COMMIT_BOOST_DOMAIN, ) } diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index 3f93ce27..65ef1c1c 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -34,6 +34,16 @@ pub const SIGNER_MODULE_NAME: &str = "signer"; /// Where the signer module should open the server pub const SIGNER_ENDPOINT_ENV: &str = "CB_SIGNER_ENDPOINT"; +pub const SIGNER_PORT_DEFAULT: u16 = 20000; + +/// Number of auth failures before rate limiting the client +pub const SIGNER_JWT_AUTH_FAIL_LIMIT_ENV: &str = "CB_SIGNER_JWT_AUTH_FAIL_LIMIT"; +pub const SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT: u32 = 3; + +/// How long to rate limit the client after auth failures +pub const SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV: &str = + "CB_SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS"; +pub const SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT: u32 = 5 * 60; /// Comma separated list module_id=jwt_secret pub const JWTS_ENV: &str = "CB_JWTS"; diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 75fd3c9d..b782999b 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -41,6 +41,9 @@ impl CommitBoostConfig { /// Validate config pub async fn validate(&self) -> Result<()> { self.pbs.pbs_config.validate(self.chain).await?; + if let Some(signer) = &self.signer { + signer.validate().await?; + } Ok(()) } diff --git a/crates/common/src/config/module.rs b/crates/common/src/config/module.rs index 16b089ca..09ccee89 100644 --- a/crates/common/src/config/module.rs +++ b/crates/common/src/config/module.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use alloy::primitives::B256; use eyre::{ContextCompat, Result}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use toml::Table; @@ -37,6 +38,8 @@ pub struct StaticModuleConfig { /// Type of the module #[serde(rename = "type")] pub kind: ModuleKind, + /// Signing ID for the module to use when requesting signatures + pub signing_id: Option, } /// Runtime config to start a module diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index e5ed6c22..f2623acf 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -4,22 +4,59 @@ use std::{ path::PathBuf, }; -use eyre::{bail, OptionExt, Result}; +use alloy::primitives::B256; +use docker_image::DockerImage; +use eyre::{bail, ensure, Context, OptionExt, Result}; use serde::{Deserialize, Serialize}; use tonic::transport::{Certificate, Identity}; use url::Url; use super::{ - load_jwt_secrets, load_optional_env_var, utils::load_env_var, CommitBoostConfig, - SIGNER_ENDPOINT_ENV, SIGNER_IMAGE_DEFAULT, + load_optional_env_var, utils::load_env_var, CommitBoostConfig, SIGNER_ENDPOINT_ENV, + SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_ENV, + SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV, + SIGNER_PORT_DEFAULT, }; use crate::{ - config::{DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV}, - signer::{ProxyStore, SignerLoader, DEFAULT_SIGNER_PORT}, + config::{ + load_jwt_secrets, DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV, + }, + signer::{ProxyStore, SignerLoader}, types::{Chain, ModuleId}, - utils::{default_host, default_u16}, + utils::{default_host, default_u16, default_u32}, }; +/// The signing configuration for a commitment module. +#[derive(Clone, Debug, PartialEq)] +pub struct ModuleSigningConfig { + /// Human-readable name of the module. + pub module_name: ModuleId, + + /// The JWT secret for the module to communicate with the signer module. + pub jwt_secret: String, + + /// A unique identifier for the module, which is used when signing requests + /// to generate signatures for this module. Must be a 32-byte hex string. + /// A leading 0x prefix is optional. + pub signing_id: B256, +} + +impl ModuleSigningConfig { + pub fn validate(&self) -> Result<()> { + // Ensure the JWT secret is not empty + if self.jwt_secret.is_empty() { + bail!("JWT secret cannot be empty"); + } + + // Ensure the signing ID is a valid B256 + if self.signing_id.is_zero() { + bail!("Signing ID cannot be zero"); + } + + Ok(()) + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct SignerConfig { @@ -27,17 +64,45 @@ pub struct SignerConfig { #[serde(default = "default_host")] pub host: Ipv4Addr, /// Port to listen for signer API calls on - #[serde(default = "default_u16::")] + #[serde(default = "default_u16::")] pub port: u16, /// Docker image of the module - #[serde(default = "default_signer")] + #[serde(default = "default_signer_image")] pub docker_image: String, + + /// Number of JWT auth failures before rate limiting an endpoint + /// If set to 0, no rate limiting will be applied + #[serde(default = "default_u32::")] + pub jwt_auth_fail_limit: u32, + + /// Duration in seconds to rate limit an endpoint after the JWT auth failure + /// limit has been reached + #[serde(default = "default_u32::")] + pub jwt_auth_fail_timeout_seconds: u32, + /// Inner type-specific configuration #[serde(flatten)] pub inner: SignerType, } -fn default_signer() -> String { +impl SignerConfig { + /// Validate the signer config + pub async fn validate(&self) -> Result<()> { + // Port must be positive + ensure!(self.port > 0, "Port must be positive"); + + // The Docker tag must parse + ensure!(!self.docker_image.is_empty(), "Docker image is empty"); + ensure!( + DockerImage::parse(&self.docker_image).is_ok(), + format!("Invalid Docker image: {}", self.docker_image) + ); + + Ok(()) + } +} + +fn default_signer_image() -> String { SIGNER_IMAGE_DEFAULT.to_string() } @@ -99,7 +164,9 @@ pub struct StartSignerConfig { pub loader: Option, pub store: Option, pub endpoint: SocketAddr, - pub jwts: HashMap, + pub mod_signing_configs: HashMap, + pub jwt_auth_fail_limit: u32, + pub jwt_auth_fail_timeout_seconds: u32, pub dirk: Option, } @@ -107,7 +174,11 @@ impl StartSignerConfig { pub fn load_from_env() -> Result { let config = CommitBoostConfig::from_env_path()?; - let jwts = load_jwt_secrets()?; + let jwt_secrets = load_jwt_secrets()?; + + // Load the module signing configs + let mod_signing_configs = load_module_signing_configs(&config, &jwt_secrets) + .wrap_err("Failed to load module signing configs")?; let signer_config = config.signer.ok_or_eyre("Signer config is missing")?; @@ -119,12 +190,31 @@ impl StartSignerConfig { SocketAddr::from((signer_config.host, signer_config.port)) }; + // Load the JWT auth fail limit the same way + let jwt_auth_fail_limit = + if let Some(limit) = load_optional_env_var(SIGNER_JWT_AUTH_FAIL_LIMIT_ENV) { + limit.parse()? + } else { + signer_config.jwt_auth_fail_limit + }; + + // Load the JWT auth fail timeout the same way + let jwt_auth_fail_timeout_seconds = if let Some(timeout) = + load_optional_env_var(SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV) + { + timeout.parse()? + } else { + signer_config.jwt_auth_fail_timeout_seconds + }; + match signer_config.inner { SignerType::Local { loader, store, .. } => Ok(StartSignerConfig { chain: config.chain, loader: Some(loader), endpoint, - jwts, + mod_signing_configs, + jwt_auth_fail_limit, + jwt_auth_fail_timeout_seconds, store, dirk: None, }), @@ -152,7 +242,9 @@ impl StartSignerConfig { Ok(StartSignerConfig { chain: config.chain, endpoint, - jwts, + mod_signing_configs, + jwt_auth_fail_limit, + jwt_auth_fail_timeout_seconds, loader: None, store, dirk: Some(DirkConfig { @@ -178,3 +270,354 @@ impl StartSignerConfig { } } } + +/// Loads the signing configurations for each module defined in the Commit Boost +/// config, coupling them with their JWT secrets and handling any potential +/// duplicates or missing values. +pub fn load_module_signing_configs( + config: &CommitBoostConfig, + jwt_secrets: &HashMap, +) -> Result> { + let mut mod_signing_configs = HashMap::new(); + if let Some(modules) = &config.modules { + let mut seen_jwt_secrets = HashMap::new(); + let mut seen_signing_ids = HashMap::new(); + for module in modules { + // Validate the module ID + ensure!(!module.id.is_empty(), "Module ID cannot be empty"); + + // Make sure it hasn't been used yet + ensure!( + !mod_signing_configs.contains_key(&module.id), + "Duplicate module config detected: ID {} is already used", + module.id + ); + + // Make sure the JWT secret is present + let jwt_secret = match jwt_secrets.get(&module.id) { + Some(secret) => secret.clone(), + None => bail!("JWT secret for module {} is missing", module.id), + }; + + // Make sure the signing ID is present + let signing_id = match &module.signing_id { + Some(id) => *id, + None => bail!("Signing ID for module {} is missing", module.id), + }; + + // Create the module signing config and validate it + let module_signing_config = + ModuleSigningConfig { module_name: module.id.clone(), jwt_secret, signing_id }; + module_signing_config + .validate() + .wrap_err(format!("Invalid signing config for module {}", module.id))?; + + // Check for duplicates in JWT secrets and signing IDs + match seen_jwt_secrets.get(&module_signing_config.jwt_secret) { + Some(existing_module) => { + bail!( + "Duplicate JWT secret detected for modules {} and {}", + existing_module, + module.id + ) + } + None => { + seen_jwt_secrets.insert(module_signing_config.jwt_secret.clone(), &module.id); + } + }; + match seen_signing_ids.get(&module_signing_config.signing_id) { + Some(existing_module) => { + bail!( + "Duplicate signing ID detected for modules {} and {}", + existing_module, + module.id + ) + } + None => { + seen_signing_ids.insert(module_signing_config.signing_id, &module.id); + signing_id + } + }; + + mod_signing_configs.insert(module.id.clone(), module_signing_config); + } + } + + Ok(mod_signing_configs) +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{b256, Uint}; + + use super::*; + use crate::config::{LogsSettings, ModuleKind, PbsConfig, StaticModuleConfig, StaticPbsConfig}; + + async fn get_base_config() -> CommitBoostConfig { + CommitBoostConfig { + chain: Chain::Hoodi, + relays: vec![], + pbs: StaticPbsConfig { + docker_image: String::from(""), + pbs_config: PbsConfig { + host: Ipv4Addr::new(127, 0, 0, 1), + port: 0, + relay_check: false, + wait_all_registrations: false, + timeout_get_header_ms: 0, + timeout_get_payload_ms: 0, + timeout_register_validator_ms: 0, + skip_sigverify: false, + min_bid_wei: Uint::<256, 4>::from(0), + late_in_slot_time_ms: 0, + extra_validation_enabled: false, + rpc_url: None, + }, + with_signer: true, + }, + muxes: None, + modules: Some(vec![]), + signer: None, + metrics: None, + logs: LogsSettings::default(), + } + } + + async fn create_module_config(id: &ModuleId, signing_id: &B256) -> StaticModuleConfig { + StaticModuleConfig { + id: id.clone(), + signing_id: Some(*signing_id), + docker_image: String::from(""), + env: None, + env_file: None, + kind: ModuleKind::Commit, + } + } + + #[tokio::test] + async fn test_good_config() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(&first_module_id, &first_signing_id).await, + create_module_config(&second_module_id, &second_signing_id).await, + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Load the mod signing configuration + let mod_signing_configs = load_module_signing_configs(&cfg, &jwts) + .wrap_err("Failed to load module signing configs")?; + assert!(mod_signing_configs.len() == 2, "Expected 2 mod signing configurations"); + + // Check the first module + let module_1 = mod_signing_configs + .get(&first_module_id) + .unwrap_or_else(|| panic!("Missing '{first_module_id}' in mod signing configs")); + assert_eq!(module_1.module_name, first_module_id, "Module name mismatch for 'test_module'"); + assert_eq!( + module_1.jwt_secret, jwts[&first_module_id], + "JWT secret mismatch for '{first_module_id}'" + ); + assert_eq!( + module_1.signing_id, first_signing_id, + "Signing ID mismatch for '{first_module_id}'" + ); + + // Check the second module + let module_2 = mod_signing_configs + .get(&second_module_id) + .unwrap_or_else(|| panic!("Missing '{second_module_id}' in mod signing configs")); + assert_eq!( + module_2.module_name, second_module_id, + "Module name mismatch for '{second_module_id}'" + ); + assert_eq!( + module_2.jwt_secret, jwts[&second_module_id], + "JWT secret mismatch for '{second_module_id}'" + ); + assert_eq!( + module_2.signing_id, second_signing_id, + "Signing ID mismatch for '{second_module_id}'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_module_names() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(&first_module_id, &first_signing_id).await, + create_module_config(&first_module_id, &second_signing_id).await, /* Duplicate module name */ + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate module names"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!("Duplicate module config detected: ID {first_module_id} is already used") + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_jwt_secrets() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(&first_module_id, &first_signing_id).await, + create_module_config(&second_module_id, &second_signing_id).await, + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "supersecret".to_string()), /* Duplicate JWT secret */ + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate JWT secrets"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!( + "Duplicate JWT secret detected for modules {first_module_id} and {second_module_id}", + ) + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_duplicate_signing_ids() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + + cfg.modules = Some(vec![ + create_module_config(&first_module_id, &first_signing_id).await, + create_module_config(&second_module_id, &first_signing_id).await, /* Duplicate signing ID */ + ]); + + let jwts = HashMap::from([ + (first_module_id.clone(), "supersecret".to_string()), + (second_module_id.clone(), "another-secret".to_string()), + ]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to duplicate signing IDs"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!( + "Duplicate signing ID detected for modules {first_module_id} and {second_module_id}", + ) + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_missing_jwt_secret() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + let second_module_id = ModuleId("2nd_test_module".to_string()); + let second_signing_id = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + + cfg.modules = Some(vec![ + create_module_config(&first_module_id, &first_signing_id).await, + create_module_config(&second_module_id, &second_signing_id).await, + ]); + + let jwts = HashMap::from([(second_module_id.clone(), "another-secret".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to missing JWT secret"); + if let Err(e) = result { + assert_eq!( + e.to_string(), + format!("JWT secret for module {first_module_id} is missing") + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_empty_jwt_secret() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = Some(vec![create_module_config(&first_module_id, &first_signing_id).await]); + + let jwts = HashMap::from([(first_module_id.clone(), "".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to empty JWT secret"); + if let Err(e) = result { + assert!(format!("{:?}", e).contains("JWT secret cannot be empty")); + } + + Ok(()) + } + + #[tokio::test] + async fn test_zero_signing_id() -> Result<()> { + let mut cfg = get_base_config().await; + let first_module_id = ModuleId("test_module".to_string()); + let first_signing_id = + b256!("0000000000000000000000000000000000000000000000000000000000000000"); + + cfg.modules = Some(vec![create_module_config(&first_module_id, &first_signing_id).await]); + + let jwts = HashMap::from([(first_module_id.clone(), "supersecret".to_string())]); + + // Make sure there was an error + let result = load_module_signing_configs(&cfg, &jwts); + assert!(result.is_err(), "Expected error due to zero signing ID"); + if let Err(e) = result { + assert!(format!("{:?}", e).contains("Signing ID cannot be zero")); + } + Ok(()) + } +} diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 67c367c5..43d6e71c 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -3,8 +3,7 @@ use std::{collections::HashMap, path::Path}; use eyre::{bail, Context, Result}; use serde::de::DeserializeOwned; -use super::JWTS_ENV; -use crate::types::ModuleId; +use crate::{config::JWTS_ENV, types::ModuleId}; pub fn load_env_var(env: &str) -> Result { std::env::var(env).wrap_err(format!("{env} is not set")) @@ -48,6 +47,7 @@ fn decode_string_to_map(raw: &str) -> Result> { mod tests { use super::*; + /// TODO: This was only used by the old JWT loader, can it be removed now? #[test] fn test_decode_string_to_map() { let raw = " KEY=VALUE , KEY2=value2 "; diff --git a/crates/common/src/pbs/types/get_header.rs b/crates/common/src/pbs/types/get_header.rs index 954aca66..7b378674 100644 --- a/crates/common/src/pbs/types/get_header.rs +++ b/crates/common/src/pbs/types/get_header.rs @@ -179,6 +179,7 @@ mod tests { &parsed.message.pubkey.into(), &parsed.message, &parsed.signature, + None, APPLICATION_BUILDER_DOMAIN ) .is_ok()) diff --git a/crates/common/src/signature.rs b/crates/common/src/signature.rs index e51e2291..cace9570 100644 --- a/crates/common/src/signature.rs +++ b/crates/common/src/signature.rs @@ -1,4 +1,7 @@ -use alloy::rpc::types::beacon::{constants::BLS_DST_SIG, BlsPublicKey, BlsSignature}; +use alloy::{ + primitives::B256, + rpc::types::beacon::{constants::BLS_DST_SIG, BlsPublicKey, BlsSignature}, +}; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -6,7 +9,7 @@ use crate::{ constants::{COMMIT_BOOST_DOMAIN, GENESIS_VALIDATORS_ROOT}, error::BlstErrorWrapper, signer::{verify_bls_signature, BlsSecretKey}, - types::Chain, + types::{self, Chain}, }; pub fn sign_message(secret_key: &BlsSecretKey, msg: &[u8]) -> BlsSignature { @@ -14,14 +17,7 @@ pub fn sign_message(secret_key: &BlsSecretKey, msg: &[u8]) -> BlsSignature { BlsSignature::from_slice(&signature) } -pub fn compute_signing_root(object_root: [u8; 32], signing_domain: [u8; 32]) -> [u8; 32] { - #[derive(Default, Debug, TreeHash)] - struct SigningData { - object_root: [u8; 32], - signing_domain: [u8; 32], - } - - let signing_data = SigningData { object_root, signing_domain }; +pub fn compute_signing_root(signing_data: &T) -> [u8; 32] { signing_data.tree_hash_root().0 } @@ -52,14 +48,27 @@ pub fn verify_signed_message( pubkey: &BlsPublicKey, msg: &T, signature: &BlsSignature, + module_signing_id: Option<&B256>, domain_mask: [u8; 4], ) -> Result<(), BlstErrorWrapper> { let domain = compute_domain(chain, domain_mask); - let signing_root = compute_signing_root(msg.tree_hash_root().0, domain); - + let signing_root = match module_signing_id { + Some(id) => compute_signing_root(&types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: msg.tree_hash_root().0, + module_signing_id: id.0, + }), + signing_domain: domain, + }), + None => compute_signing_root(&types::SigningData { + object_root: msg.tree_hash_root().0, + signing_domain: domain, + }), + }; verify_bls_signature(pubkey, &signing_root, signature) } +/// Signs a message with the Beacon builder domain. pub fn sign_builder_message( chain: Chain, secret_key: &BlsSecretKey, @@ -74,7 +83,9 @@ pub fn sign_builder_root( object_root: [u8; 32], ) -> BlsSignature { let domain = chain.builder_domain(); - let signing_root = compute_signing_root(object_root, domain); + let signing_data = + types::SigningData { object_root: object_root.tree_hash_root().0, signing_domain: domain }; + let signing_root = compute_signing_root(&signing_data); sign_message(secret_key, &signing_root) } @@ -82,9 +93,19 @@ pub fn sign_commit_boost_root( chain: Chain, secret_key: &BlsSecretKey, object_root: [u8; 32], + module_signing_id: Option<[u8; 32]>, ) -> BlsSignature { let domain = compute_domain(chain, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(object_root, domain); + let signing_root = match module_signing_id { + Some(id) => compute_signing_root(&types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: object_root, + module_signing_id: id, + }), + signing_domain: domain, + }), + None => compute_signing_root(&types::SigningData { object_root, signing_domain: domain }), + }; sign_message(secret_key, &signing_root) } diff --git a/crates/common/src/signer/constants.rs b/crates/common/src/signer/constants.rs deleted file mode 100644 index aa834f91..00000000 --- a/crates/common/src/signer/constants.rs +++ /dev/null @@ -1 +0,0 @@ -pub const DEFAULT_SIGNER_PORT: u16 = 20000; diff --git a/crates/common/src/signer/mod.rs b/crates/common/src/signer/mod.rs index b6dce29d..e0a164a7 100644 --- a/crates/common/src/signer/mod.rs +++ b/crates/common/src/signer/mod.rs @@ -1,10 +1,8 @@ -mod constants; mod loader; mod schemes; mod store; mod types; -pub use constants::*; pub use loader::*; pub use schemes::*; pub use store::*; diff --git a/crates/common/src/signer/schemes/bls.rs b/crates/common/src/signer/schemes/bls.rs index f133b2bc..f3a511e7 100644 --- a/crates/common/src/signer/schemes/bls.rs +++ b/crates/common/src/signer/schemes/bls.rs @@ -38,14 +38,26 @@ impl BlsSigner { } } - pub async fn sign(&self, chain: Chain, object_root: [u8; 32]) -> BlsSignature { + pub async fn sign( + &self, + chain: Chain, + object_root: [u8; 32], + module_signing_id: Option<[u8; 32]>, + ) -> BlsSignature { match self { - BlsSigner::Local(sk) => sign_commit_boost_root(chain, sk, object_root), + BlsSigner::Local(sk) => { + sign_commit_boost_root(chain, sk, object_root, module_signing_id) + } } } - pub async fn sign_msg(&self, chain: Chain, msg: &impl TreeHash) -> BlsSignature { - self.sign(chain, msg.tree_hash_root().0).await + pub async fn sign_msg( + &self, + chain: Chain, + msg: &impl TreeHash, + module_signing_id: Option<[u8; 32]>, + ) -> BlsSignature { + self.sign(chain, msg.tree_hash_root().0, module_signing_id).await } } diff --git a/crates/common/src/signer/schemes/ecdsa.rs b/crates/common/src/signer/schemes/ecdsa.rs index 612df5e3..73bf7272 100644 --- a/crates/common/src/signer/schemes/ecdsa.rs +++ b/crates/common/src/signer/schemes/ecdsa.rs @@ -10,7 +10,7 @@ use tree_hash::TreeHash; use crate::{ constants::COMMIT_BOOST_DOMAIN, signature::{compute_domain, compute_signing_root}, - types::Chain, + types::{self, Chain}, }; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -87,22 +87,39 @@ impl EcdsaSigner { &self, chain: Chain, object_root: [u8; 32], + module_signing_id: Option<[u8; 32]>, ) -> Result { match self { EcdsaSigner::Local(sk) => { let domain = compute_domain(chain, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(object_root, domain).into(); + let signing_root = match module_signing_id { + Some(id) => { + let signing_data = types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: object_root, + module_signing_id: id, + }), + signing_domain: domain, + }; + compute_signing_root(&signing_data).into() + } + None => { + let signing_data = + types::SigningData { object_root, signing_domain: domain }; + compute_signing_root(&signing_data).into() + } + }; sk.sign_hash_sync(&signing_root).map(EcdsaSignature::from) } } } - pub async fn sign_msg( &self, chain: Chain, msg: &impl TreeHash, + module_signing_id: Option<[u8; 32]>, ) -> Result { - self.sign(chain, msg.tree_hash_root().0).await + self.sign(chain, msg.tree_hash_root().0, module_signing_id).await } } @@ -124,15 +141,16 @@ mod test { use super::*; #[tokio::test] - async fn test_ecdsa_signer() { + async fn test_ecdsa_signer_noncommit() { let pk = bytes!("88bcd6672d95bcba0d52a3146494ed4d37675af4ed2206905eb161aa99a6c0d1"); let signer = EcdsaSigner::new_from_bytes(&pk).unwrap(); let object_root = [1; 32]; - let signature = signer.sign(Chain::Holesky, object_root).await.unwrap(); + let signature = signer.sign(Chain::Holesky, object_root, None).await.unwrap(); let domain = compute_domain(Chain::Holesky, COMMIT_BOOST_DOMAIN); - let msg = compute_signing_root(object_root, domain); + let signing_data = types::SigningData { object_root, signing_domain: domain }; + let msg = compute_signing_root(&signing_data); assert_eq!(msg, hex!("219ca7a673b2cbbf67bec6c9f60f78bd051336d57b68d1540190f30667e86725")); @@ -140,4 +158,31 @@ mod test { let verified = verify_ecdsa_signature(&address, &msg, &signature); assert!(verified.is_ok()); } + + #[tokio::test] + async fn test_ecdsa_signer_prop_commit() { + let pk = bytes!("88bcd6672d95bcba0d52a3146494ed4d37675af4ed2206905eb161aa99a6c0d1"); + let signer = EcdsaSigner::new_from_bytes(&pk).unwrap(); + + let object_root = [1; 32]; + let module_signing_id = [2; 32]; + let signature = + signer.sign(Chain::Hoodi, object_root, Some(module_signing_id)).await.unwrap(); + + let domain = compute_domain(Chain::Hoodi, COMMIT_BOOST_DOMAIN); + let signing_data = types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: object_root, + module_signing_id, + }), + signing_domain: domain, + }; + let msg = compute_signing_root(&signing_data); + + assert_eq!(msg, hex!("8cd49ccf2f9b0297796ff96ce5f7c5d26e20a59d0032ee2ad6249dcd9682b808")); + + let address = signer.address(); + let verified = verify_ecdsa_signature(&address, &msg, &signature); + assert!(verified.is_ok()); + } } diff --git a/crates/common/src/signer/store.rs b/crates/common/src/signer/store.rs index bd23c120..9e251dd9 100644 --- a/crates/common/src/signer/store.rs +++ b/crates/common/src/signer/store.rs @@ -532,7 +532,8 @@ mod test { delegator: consensus_signer.pubkey(), proxy: proxy_signer.pubkey(), }; - let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0).await; + let signature = + consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0, None).await; let delegation = SignedProxyDelegationBls { signature, message }; let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; @@ -645,7 +646,8 @@ mod test { delegator: consensus_signer.pubkey(), proxy: proxy_signer.pubkey(), }; - let signature = consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0).await; + let signature = + consensus_signer.sign(Chain::Mainnet, message.tree_hash_root().0, None).await; let delegation = SignedProxyDelegationBls { signature, message }; let proxy_signer = BlsProxySigner { signer: proxy_signer, delegation }; @@ -674,7 +676,7 @@ mod test { .join(consensus_signer.pubkey().to_string()) .join("TEST_MODULE") .join("bls") - .join(format!("{}.sig", proxy_signer.pubkey().to_string())) + .join(format!("{}.sig", proxy_signer.pubkey())) ) .unwrap() ) diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 5293a789..a9c8ebfd 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -4,6 +4,7 @@ use alloy::primitives::{hex, Bytes}; use derive_more::{Deref, Display, From, Into}; use eyre::{bail, Context}; use serde::{Deserialize, Serialize}; +use tree_hash_derive::TreeHash; use crate::{constants::APPLICATION_BUILDER_DOMAIN, signature::compute_domain}; @@ -283,6 +284,22 @@ impl<'de> Deserialize<'de> for Chain { } } +/// Structure for signatures used in Beacon chain operations +#[derive(Default, Debug, TreeHash)] +pub struct SigningData { + pub object_root: [u8; 32], + pub signing_domain: [u8; 32], +} + +/// Structure for signatures used for proposer commitments in Commit Boost. +/// The signing root of this struct must be used as the object_root of a +/// SigningData for signatures. +#[derive(Default, Debug, TreeHash)] +pub struct PropCommitSigningInfo { + pub data: [u8; 32], + pub module_signing_id: [u8; 32], +} + /// Returns seconds_per_slot and genesis_fork_version from a spec, such as /// returned by /eth/v1/config/spec ref: https://ethereum.github.io/beacon-APIs/#/Config/getSpec /// Try to load two formats: diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 37119580..a1dcb7cb 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -137,6 +137,10 @@ pub const fn default_u64() -> u64 { U } +pub const fn default_u32() -> u32 { + U +} + pub const fn default_u16() -> u16 { U } diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index e4922245..b0ede1ab 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -498,6 +498,7 @@ fn validate_signature( &received_relay_pubkey, &message, signature, + None, APPLICATION_BUILDER_DOMAIN, ) .map_err(ValidationError::Sigverify)?; diff --git a/crates/signer/src/error.rs b/crates/signer/src/error.rs index 477e9e42..a2a113f3 100644 --- a/crates/signer/src/error.rs +++ b/crates/signer/src/error.rs @@ -27,6 +27,9 @@ pub enum SignerModuleError { #[error("internal error: {0}")] Internal(String), + + #[error("rate limited for {0} more seconds")] + RateLimited(f64), } impl IntoResponse for SignerModuleError { @@ -45,6 +48,9 @@ impl IntoResponse for SignerModuleError { (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string()) } SignerModuleError::SignerError(err) => (StatusCode::BAD_REQUEST, err.to_string()), + SignerModuleError::RateLimited(duration) => { + (StatusCode::TOO_MANY_REQUESTS, format!("rate limited for {duration:?}")) + } } .into_response() } diff --git a/crates/signer/src/manager/dirk.rs b/crates/signer/src/manager/dirk.rs index 4c2d909f..08c73def 100644 --- a/crates/signer/src/manager/dirk.rs +++ b/crates/signer/src/manager/dirk.rs @@ -1,14 +1,14 @@ use std::{collections::HashMap, io::Write, path::PathBuf}; -use alloy::{hex, rpc::types::beacon::constants::BLS_SIGNATURE_BYTES_LEN}; +use alloy::{hex, primitives::B256, rpc::types::beacon::constants::BLS_SIGNATURE_BYTES_LEN}; use blsful::inner_types::{Field, G2Affine, G2Projective, Group, Scalar}; use cb_common::{ commit::request::{ConsensusProxyMap, ProxyDelegation, SignedProxyDelegation}, config::{DirkConfig, DirkHostConfig}, constants::COMMIT_BOOST_DOMAIN, - signature::compute_domain, + signature::{compute_domain, compute_signing_root}, signer::{BlsPublicKey, BlsSignature, ProxyStore}, - types::{Chain, ModuleId}, + types::{self, Chain, ModuleId}, }; use eyre::{bail, OptionExt}; use futures::{future::join_all, stream::FuturesUnordered, FutureExt, StreamExt}; @@ -192,14 +192,15 @@ impl DirkManager { pub async fn request_consensus_signature( &self, pubkey: &BlsPublicKey, - object_root: [u8; 32], + object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { match self.consensus_accounts.get(pubkey) { Some(Account::Simple(account)) => { - self.request_simple_signature(account, object_root).await + self.request_simple_signature(account, object_root, module_signing_id).await } Some(Account::Distributed(account)) => { - self.request_distributed_signature(account, object_root).await + self.request_distributed_signature(account, object_root, module_signing_id).await } None => Err(SignerModuleError::UnknownConsensusSigner(pubkey.to_vec())), } @@ -209,14 +210,15 @@ impl DirkManager { pub async fn request_proxy_signature( &self, pubkey: &BlsPublicKey, - object_root: [u8; 32], + object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { match self.proxy_accounts.get(pubkey) { Some(ProxyAccount { inner: Account::Simple(account), .. }) => { - self.request_simple_signature(account, object_root).await + self.request_simple_signature(account, object_root, module_signing_id).await } Some(ProxyAccount { inner: Account::Distributed(account), .. }) => { - self.request_distributed_signature(account, object_root).await + self.request_distributed_signature(account, object_root, module_signing_id).await } None => Err(SignerModuleError::UnknownProxySigner(pubkey.to_vec())), } @@ -226,13 +228,23 @@ impl DirkManager { async fn request_simple_signature( &self, account: &SimpleAccount, - object_root: [u8; 32], + object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { let domain = compute_domain(self.chain, COMMIT_BOOST_DOMAIN); + let data = match module_signing_id { + Some(id) => compute_signing_root(&types::PropCommitSigningInfo { + data: *object_root, + module_signing_id: id.0, + }) + .to_vec(), + None => object_root.to_vec(), + }; + let response = SignerClient::new(account.connection.clone()) .sign(SignRequest { - data: object_root.to_vec(), + data, domain: domain.to_vec(), id: Some(sign_request::Id::PublicKey(account.public_key.to_vec())), }) @@ -256,16 +268,27 @@ impl DirkManager { async fn request_distributed_signature( &self, account: &DistributedAccount, - object_root: [u8; 32], + object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { let mut partials = Vec::with_capacity(account.participants.len()); let mut requests = Vec::with_capacity(account.participants.len()); + let data = match module_signing_id { + Some(id) => compute_signing_root(&types::PropCommitSigningInfo { + data: *object_root, + module_signing_id: id.0, + }) + .to_vec(), + None => object_root.to_vec(), + }; + for (id, channel) in account.participants.iter() { + let data_copy = data.clone(); let request = async move { SignerClient::new(channel.clone()) .sign(SignRequest { - data: object_root.to_vec(), + data: data_copy, domain: compute_domain(self.chain, COMMIT_BOOST_DOMAIN).to_vec(), id: Some(sign_request::Id::Account(account.name.clone())), }) @@ -336,7 +359,7 @@ impl DirkManager { let message = ProxyDelegation { delegator: consensus, proxy: proxy_account.inner.public_key() }; let delegation_signature = - self.request_consensus_signature(&consensus, message.tree_hash_root().0).await?; + self.request_consensus_signature(&consensus, &message.tree_hash_root().0, None).await?; let delegation = SignedProxyDelegation { message, signature: delegation_signature }; diff --git a/crates/signer/src/manager/local.rs b/crates/signer/src/manager/local.rs index 6d9e35fe..a242a754 100644 --- a/crates/signer/src/manager/local.rs +++ b/crates/signer/src/manager/local.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use alloy::{primitives::Address, rpc::types::beacon::BlsSignature}; +use alloy::{ + primitives::{Address, B256}, + rpc::types::beacon::BlsSignature, +}; use cb_common::{ commit::request::{ ConsensusProxyMap, ProxyDelegationBls, ProxyDelegationEcdsa, SignedProxyDelegationBls, @@ -95,7 +98,7 @@ impl LocalSigningManager { let proxy_pubkey = signer.pubkey(); let message = ProxyDelegationBls { delegator, proxy: proxy_pubkey }; - let signature = self.sign_consensus(&delegator, &message.tree_hash_root().0).await?; + let signature = self.sign_consensus(&delegator, &message.tree_hash_root().0, None).await?; let delegation = SignedProxyDelegationBls { signature, message }; let proxy_signer = BlsProxySigner { signer, delegation }; @@ -114,7 +117,7 @@ impl LocalSigningManager { let proxy_address = signer.address(); let message = ProxyDelegationEcdsa { delegator, proxy: proxy_address }; - let signature = self.sign_consensus(&delegator, &message.tree_hash_root().0).await?; + let signature = self.sign_consensus(&delegator, &message.tree_hash_root().0, None).await?; let delegation = SignedProxyDelegationEcdsa { signature, message }; let proxy_signer = EcdsaProxySigner { signer, delegation }; @@ -130,12 +133,16 @@ impl LocalSigningManager { &self, pubkey: &BlsPublicKey, object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { let signer = self .consensus_signers .get(pubkey) .ok_or(SignerModuleError::UnknownConsensusSigner(pubkey.to_vec()))?; - let signature = signer.sign(self.chain, *object_root).await; + let signature = match module_signing_id { + Some(id) => signer.sign(self.chain, *object_root, Some(id.0)).await, + None => signer.sign(self.chain, *object_root, None).await, + }; Ok(signature) } @@ -144,13 +151,17 @@ impl LocalSigningManager { &self, pubkey: &BlsPublicKey, object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { let bls_proxy = self .proxy_signers .bls_signers .get(pubkey) .ok_or(SignerModuleError::UnknownProxySigner(pubkey.to_vec()))?; - let signature = bls_proxy.sign(self.chain, *object_root).await; + let signature = match module_signing_id { + Some(id) => bls_proxy.sign(self.chain, *object_root, Some(id.0)).await, + None => bls_proxy.sign(self.chain, *object_root, None).await, + }; Ok(signature) } @@ -158,13 +169,17 @@ impl LocalSigningManager { &self, address: &Address, object_root: &[u8; 32], + module_signing_id: Option<&B256>, ) -> Result { let ecdsa_proxy = self .proxy_signers .ecdsa_signers .get(address) .ok_or(SignerModuleError::UnknownProxySigner(address.to_vec()))?; - let signature = ecdsa_proxy.sign(self.chain, *object_root).await?; + let signature = match module_signing_id { + Some(id) => ecdsa_proxy.sign(self.chain, *object_root, Some(id.0)).await?, + None => ecdsa_proxy.sign(self.chain, *object_root, None).await?, + }; Ok(signature) } @@ -287,9 +302,50 @@ mod tests { (signing_manager, consensus_pk) } + mod test_bls { + use cb_common::{ + constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, + signer::verify_bls_signature, types, + }; + + use super::*; + + #[tokio::test] + async fn test_key_signs_message() { + let (signing_manager, consensus_pk) = init_signing_manager(); + + let data_root = B256::random(); + let module_signing_id = B256::random(); + + let sig = signing_manager + .sign_consensus( + &consensus_pk.try_into().unwrap(), + &data_root, + Some(&module_signing_id), + ) + .await + .unwrap(); + + // Verify signature + let domain = compute_domain(CHAIN, COMMIT_BOOST_DOMAIN); + let signing_root = compute_signing_root(&types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: data_root.tree_hash_root().0, + module_signing_id: module_signing_id.0, + }), + signing_domain: domain, + }); + + let validation_result = verify_bls_signature(&consensus_pk, &signing_root, &sig); + + assert!(validation_result.is_ok(), "Keypair must produce valid signatures of messages.") + } + } + mod test_proxy_bls { use cb_common::{ - constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, signer::verify_bls_signature, + constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, + signer::verify_bls_signature, types, }; use super::*; @@ -345,15 +401,22 @@ mod tests { let proxy_pk = signed_delegation.message.proxy; let data_root = B256::random(); + let module_signing_id = B256::random(); let sig = signing_manager - .sign_proxy_bls(&proxy_pk.try_into().unwrap(), &data_root) + .sign_proxy_bls(&proxy_pk.try_into().unwrap(), &data_root, Some(&module_signing_id)) .await .unwrap(); // Verify signature let domain = compute_domain(CHAIN, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(data_root.tree_hash_root().0, domain); + let signing_root = compute_signing_root(&types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: data_root.tree_hash_root().0, + module_signing_id: module_signing_id.0, + }), + signing_domain: domain, + }); let validation_result = verify_bls_signature(&proxy_pk, &signing_root, &sig); @@ -367,7 +430,7 @@ mod tests { mod test_proxy_ecdsa { use cb_common::{ constants::COMMIT_BOOST_DOMAIN, signature::compute_domain, - signer::verify_ecdsa_signature, + signer::verify_ecdsa_signature, types, }; use super::*; @@ -423,15 +486,26 @@ mod tests { let proxy_pk = signed_delegation.message.proxy; let data_root = B256::random(); + let module_signing_id = B256::random(); let sig = signing_manager - .sign_proxy_ecdsa(&proxy_pk.try_into().unwrap(), &data_root) + .sign_proxy_ecdsa( + &proxy_pk.try_into().unwrap(), + &data_root, + Some(&module_signing_id), + ) .await .unwrap(); // Verify signature let domain = compute_domain(CHAIN, COMMIT_BOOST_DOMAIN); - let signing_root = compute_signing_root(data_root.tree_hash_root().0, domain); + let signing_root = compute_signing_root(&types::SigningData { + object_root: compute_signing_root(&types::PropCommitSigningInfo { + data: data_root.tree_hash_root().0, + module_signing_id: module_signing_id.0, + }), + signing_domain: domain, + }); let validation_result = verify_ecdsa_signature(&proxy_pk, &signing_root, &sig); diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index a965f057..ff570264 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -1,7 +1,12 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + net::SocketAddr, + sync::Arc, + time::{Duration, Instant}, +}; use axum::{ - extract::{Request, State}, + extract::{ConnectInfo, Request, State}, http::StatusCode, middleware::{self, Next}, response::{IntoResponse, Response}, @@ -20,7 +25,7 @@ use cb_common::{ SignProxyRequest, SignRequest, }, }, - config::StartSignerConfig, + config::{ModuleSigningConfig, StartSignerConfig}, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, types::{Chain, Jwt, ModuleId}, utils::{decode_jwt, validate_jwt}, @@ -41,33 +46,70 @@ use crate::{ /// Implements the Signer API and provides a service for signing requests pub struct SigningService; +// Tracker for a peer's JWT failures +struct JwtAuthFailureInfo { + // Number of auth failures since the first failure was tracked + failure_count: u32, + + // Time of the last auth failure + last_failure: Instant, +} + #[derive(Clone)] struct SigningState { /// Manager handling different signing methods manager: Arc>, - /// Map of modules ids to JWT secrets. This also acts as registry of all - /// modules running - jwts: Arc>, + + /// Map of modules ids to JWT configurations. This also acts as registry of + /// all modules running + jwts: Arc>, + + /// Map of JWT failures per peer + jwt_auth_failures: Arc>>, + + // JWT auth failure settings + jwt_auth_fail_limit: u32, + jwt_auth_fail_timeout: Duration, } impl SigningService { pub async fn run(config: StartSignerConfig) -> eyre::Result<()> { - if config.jwts.is_empty() { + if config.mod_signing_configs.is_empty() { warn!("Signing service was started but no module is registered. Exiting"); return Ok(()); } - let module_ids: Vec = config.jwts.keys().cloned().map(Into::into).collect(); + let module_ids: Vec = + config.mod_signing_configs.keys().cloned().map(Into::into).collect(); let state = SigningState { manager: Arc::new(RwLock::new(start_manager(config.clone()).await?)), - jwts: config.jwts.into(), + jwts: config.mod_signing_configs.into(), + jwt_auth_failures: Arc::new(RwLock::new(HashMap::new())), + jwt_auth_fail_limit: config.jwt_auth_fail_limit, + jwt_auth_fail_timeout: Duration::from_secs(config.jwt_auth_fail_timeout_seconds as u64), }; - let loaded_consensus = state.manager.read().await.available_consensus_signers(); - let loaded_proxies = state.manager.read().await.available_proxy_signers(); + // Get the signer counts + let loaded_consensus: usize; + let loaded_proxies: usize; + { + let manager = state.manager.read().await; + loaded_consensus = manager.available_consensus_signers(); + loaded_proxies = manager.available_proxy_signers(); + } - info!(version = COMMIT_BOOST_VERSION, commit_hash = COMMIT_BOOST_COMMIT, modules =? module_ids, endpoint =? config.endpoint, loaded_consensus, loaded_proxies, "Starting signing service"); + info!( + version = COMMIT_BOOST_VERSION, + commit_hash = COMMIT_BOOST_COMMIT, + modules =? module_ids, + endpoint =? config.endpoint, + loaded_consensus, + loaded_proxies, + jwt_auth_fail_limit =? state.jwt_auth_fail_limit, + jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout, + "Starting signing service" + ); SigningService::init_metrics(config.chain)?; @@ -79,7 +121,8 @@ impl SigningService { .route(RELOAD_PATH, post(handle_reload)) .with_state(state.clone()) .route_layer(middleware::from_fn(log_request)) - .route(STATUS_PATH, get(handle_status)); + .route(STATUS_PATH, get(handle_status)) + .into_make_service_with_connect_info::(); let listener = TcpListener::bind(config.endpoint).await?; @@ -95,9 +138,76 @@ impl SigningService { async fn jwt_auth( State(state): State, TypedHeader(auth): TypedHeader>, + addr: ConnectInfo, mut req: Request, next: Next, ) -> Result { + // Check if the request needs to be rate limited + let client_ip = addr.ip().to_string(); + check_jwt_rate_limit(&state, &client_ip).await?; + + // Process JWT authorization + match check_jwt_auth(&auth, &state).await { + Ok(module_id) => { + req.extensions_mut().insert(module_id); + Ok(next.run(req).await) + } + Err(SignerModuleError::Unauthorized) => { + let mut failures = state.jwt_auth_failures.write().await; + let failure_info = failures + .entry(client_ip) + .or_insert(JwtAuthFailureInfo { failure_count: 0, last_failure: Instant::now() }); + failure_info.failure_count += 1; + failure_info.last_failure = Instant::now(); + Err(SignerModuleError::Unauthorized) + } + Err(err) => Err(err), + } +} + +/// Checks if the incoming request needs to be rate limited due to previous JWT +/// authentication failures +async fn check_jwt_rate_limit( + state: &SigningState, + client_ip: &String, +) -> Result<(), SignerModuleError> { + let mut failures = state.jwt_auth_failures.write().await; + + // Ignore clients that don't have any failures + if let Some(failure_info) = failures.get(client_ip) { + // If the last failure was more than the timeout ago, remove this entry so it's + // eligible again + let elapsed = failure_info.last_failure.elapsed(); + if elapsed > state.jwt_auth_fail_timeout { + debug!("Removing {client_ip} from JWT auth failure list"); + failures.remove(client_ip); + return Ok(()); + } + + // If the failure threshold hasn't been met yet, don't rate limit + if failure_info.failure_count < state.jwt_auth_fail_limit { + debug!( + "Client {client_ip} has {}/{} JWT auth failures, no rate limit applied", + failure_info.failure_count, state.jwt_auth_fail_limit + ); + return Ok(()); + } + + // Rate limit the request + let remaining = state.jwt_auth_fail_timeout - elapsed; + warn!("Client {client_ip} is rate limited for {remaining:?} more seconds due to JWT auth failures"); + return Err(SignerModuleError::RateLimited(remaining.as_secs_f64())); + } + + debug!("Client {client_ip} has no JWT auth failures, no rate limit applied"); + Ok(()) +} + +/// Checks if a request can successfully authenticate with the JWT secret +async fn check_jwt_auth( + auth: &Authorization, + state: &SigningState, +) -> Result { let jwt: Jwt = auth.token().to_string().into(); // We first need to decode it to get the module id and then validate it @@ -107,19 +217,16 @@ async fn jwt_auth( SignerModuleError::Unauthorized })?; - let jwt_secret = state.jwts.get(&module_id).ok_or_else(|| { + let jwt_config = state.jwts.get(&module_id).ok_or_else(|| { error!("Unauthorized request. Was the module started correctly?"); SignerModuleError::Unauthorized })?; - validate_jwt(jwt, jwt_secret).map_err(|e| { + validate_jwt(jwt, &jwt_config.jwt_secret).map_err(|e| { error!("Unauthorized request. Invalid JWT: {e}"); SignerModuleError::Unauthorized })?; - - req.extensions_mut().insert(module_id); - - Ok(next.run(req).await) + Ok(module_id) } /// Requests logging middleware layer @@ -163,38 +270,44 @@ async fn handle_request_signature( Json(request): Json, ) -> Result { let req_id = Uuid::new_v4(); - + let signing_id = &state.jwts[&module_id].signing_id; debug!(event = "request_signature", ?module_id, %request, ?req_id, "New request"); let manager = state.manager.read().await; let res = match &*manager { SigningManager::Local(local_manager) => match request { - SignRequest::Consensus(SignConsensusRequest { object_root, pubkey }) => local_manager - .sign_consensus(&pubkey, &object_root) - .await - .map(|sig| Json(sig).into_response()), - SignRequest::ProxyBls(SignProxyRequest { object_root, proxy: bls_key }) => { + SignRequest::Consensus(SignConsensusRequest { ref object_root, ref pubkey }) => { local_manager - .sign_proxy_bls(&bls_key, &object_root) + .sign_consensus(pubkey, object_root, Some(signing_id)) .await .map(|sig| Json(sig).into_response()) } - SignRequest::ProxyEcdsa(SignProxyRequest { object_root, proxy: ecdsa_key }) => { + SignRequest::ProxyBls(SignProxyRequest { ref object_root, proxy: ref bls_key }) => { local_manager - .sign_proxy_ecdsa(&ecdsa_key, &object_root) + .sign_proxy_bls(bls_key, object_root, Some(signing_id)) + .await + .map(|sig| Json(sig).into_response()) + } + SignRequest::ProxyEcdsa(SignProxyRequest { ref object_root, proxy: ref ecdsa_key }) => { + local_manager + .sign_proxy_ecdsa(ecdsa_key, object_root, Some(signing_id)) .await .map(|sig| Json(sig).into_response()) } }, SigningManager::Dirk(dirk_manager) => match request { - SignRequest::Consensus(SignConsensusRequest { object_root, pubkey }) => dirk_manager - .request_consensus_signature(&pubkey, *object_root) - .await - .map(|sig| Json(sig).into_response()), - SignRequest::ProxyBls(SignProxyRequest { object_root, proxy: bls_key }) => dirk_manager - .request_proxy_signature(&bls_key, *object_root) - .await - .map(|sig| Json(sig).into_response()), + SignRequest::Consensus(SignConsensusRequest { ref object_root, ref pubkey }) => { + dirk_manager + .request_consensus_signature(pubkey, object_root, Some(signing_id)) + .await + .map(|sig| Json(sig).into_response()) + } + SignRequest::ProxyBls(SignProxyRequest { ref object_root, proxy: ref bls_key }) => { + dirk_manager + .request_proxy_signature(bls_key, object_root, Some(signing_id)) + .await + .map(|sig| Json(sig).into_response()) + } SignRequest::ProxyEcdsa(_) => { error!( event = "request_signature", diff --git a/docs/docs/developing/prop-commit-signing.md b/docs/docs/developing/prop-commit-signing.md new file mode 100644 index 00000000..c838dcbf --- /dev/null +++ b/docs/docs/developing/prop-commit-signing.md @@ -0,0 +1,175 @@ +# Requesting Proposer Commitment Signatures with Commit Boost + +When you create a new validator on the Ethereum network, one of the steps is the generation of a new BLS private key (commonly known as the "validator key" or the "signer key") and its corresponding BLS public key (the "validator pubkey", used as an identifier). Typically this private key will be used by an Ethereum consensus client to sign things such as attestations and blocks for publication on the Beacon chain. These signatures prove that you, as the owner of that private key, approve of the data being signed. However, as general-purpose private keys, they can also be used to sign *other* arbitrary messages not destined for the Beacon chain. + +Commit Boost takes advantage of this by offering a standard known as **proposer commitments**. These are arbitrary messages (albeit with some important rules), similar to the kind used on the Beacon chain, that have been signed by one of the owner's private keys. Modules interested in leveraging Commit Boost's proposer commitments can construct their own data in whatever format they like and request that Commit Boost's **signer service** generate a signature for it with a particular private key. The module can then use that signature to verify the data was signed by that user. + +Commit Boost supports proposer commitment signatures for both BLS private keys (identified by their public key) and ECDSA private keys (identified by their Ethereum address). + + +## Rules of Preconfirmation Signatures + +Preconfirmation signatures produced by Commit Boost's signer service conform to the following rules: + +- Signatures are **unique** to a given EVM chain (identified by its [chain ID](https://chainlist.org/)). Signatures generated for one chain will not work on a different chain. +- Signatures are **unique** to Commit Boost proposer commitments. The signer service **cannot** be used to create signatures that could be used for other applications, such as for attestations on the Beacon chain. While the signer service has access to the same validator private keys used to attest on the Beacon chain, it cannot create signatures that would get you slashed on the Beacon chain. +- Signatures are **unique** to a particular module. One module cannot, for example, request an identical payload as another module and effectively "forge" a signature for the second module; identical payloads from two separate modules will result in two separate signatures. +- The data payload being signed must be a **32-byte array**, typically serializd as a 64-character hex string with an optional `0x` prefix. The value itself is arbitrary, as long as it has meaning to the requester - though it is typically the 256-bit hash of some kind of data. +- If requesting a signature from a BLS key, the resulting signature will be a standard BLS signature (96 bytes in length). +- If requesting a signature from an ECDSA key, the resulting signature will be a standard Ethereum RSV signature (65 bytes in length). + + +## Configuring a Module for Proposer Commitments + +Commit Boost's signer service must be configured prior to launching to expect requests from your module. There are two main parts: + +1. An entry for your module into [Commit Boost's configuration file](../get_started/configuration.md#custom-module). This must include a unique ID for your module, the line `type = "commit"`, and include a unique [signing ID](#the-signing-id) for your module. Generally you should provide values for these in your documentation, so your users can reference it when configuring their own Commit Boost node. + +2. A JWT secret used by your module to authenticate with the signer in HTTP requests. This must be a string that both the Commit Boost signer can read and your module can read, but no other modules should be allowed to access it. The user should be responsible for determining an appropriate secret and providing it to the Commit Boost signer service securely; your module will need some way to accept this, typically via a command line argument that accepts a path to a file with the secret or as an environment variable. + +Once the user has configured both Commit Boost and your module with these settings, your module will be able to authenticate with the signer service and request signatures. + + +## The Signing ID + +Your module's signing ID is a 32-byte value that is used as a unique identifier within the signing process. Preconfirmation signatures incorporate this value along with the data being signed as a way to create signatures that are exclusive to your module, so other modules can't maliciously construct signatures that appear to be from your module. Your module must have this ID incorporated into itself ahead of time, and the user must include this same ID within their Commit Boost configuration file section for your module. Commit Boost does not maintain a global registry of signing IDs, so this is a value you should provide to your users in your documentation. + +The Signing ID is decoupled from your module's human-readable name (the `module_id` field in the Commit Boost configuration file) so that any changes to your module name will not invalidate signatures from previous versions. Similarly, if you don't change the module ID but *want* to invalidate previous signatures, you can modify the signing ID and it will do so. Just ensure your users are made aware of the change, so they can update it in their Commit Boost configuration files accordingly. + + +## Structure of a Signature + +The form proposer commitment signatures take depends on the type of signature being requested. BLS signatures take the [standard form](https://eth2book.info/latest/part2/building_blocks/signatures/) (96-byte values). ECDSA (Ethereum EL) signatures take the [standard Ethereum ECDSA `r,s,v` signature form](https://forum.openzeppelin.com/t/sign-it-like-you-mean-it-creating-and-verifying-ethereum-signatures/697). In both cases, the data being signed is a 32-byte hash - the root hash of an SSZ Merkle tree, described below: + +
+ + + +
+ +where: + +- `Request Data` is a 32-byte array that serves as the data you want to sign. This is typically a hash of some more complex data on its own that your module constructs. + +- `Signing ID` is your module's 32-byte signing ID. The signer service will load this for your module from its configuration file. + +- `Domain` is the 32-byte output of the [compute_domain()](https://eth2book.info/capella/part2/building_blocks/signatures/#domain-separation-and-forks) function in the Beacon specification. The 4-byte domain type in this case is not a standard Beacon domain type, but rather Commit Boost's own domain type: `0x6D6D6F43`. + +The data signed in a proposer commitment is the 32-byte root of this tree (the green `Root` box). Note that calculating this will involve calculating the Merkle Root of two separate trees: first the blue data subtree (with the original request data and the signing ID) to establish the blue `Root` value, and then again with a tree created from that value and the `Domain`. + +Many languages provide libraries for computing the root of an SSZ Merkle tree, such as [fastssz for Go](https://github.com/ferranbt/fastssz) or [tree_hash for Rust](https://docs.rs/tree_hash/latest/tree_hash/). When verifying proposer commitment signatures, use a library that supports Merkle tree root hashing, the `compute_domain()` operation, and validation for signatures generated by your key of choice. + + +## Requesting a Proposer Commitment from the Signer + +Prior to requesting a signature from the signer service, first ensure that Commit Boost has been [configured](#configuring-a-module-for-proposer-commitments) with your module's signing ID and JWT secret. + +The signer service can be accessed by an HTTP API. In Docker mode, this will be within the `cb_signer` container at the `/signer/v1/request_signature` route (for example, using the default port of `20000`, the endpoint will be `http://cb_signer:20000/signer/v1/request_signature`). Submitting a request must be done via the `POST` method. + + +### Headers + +- Set `Content-Type` set to `application/json`. +- Set `Accept` to `application/json`, as responses are quoted strings. Other formats are not currently supported. +- Set `Authorization` to a standard JWT string representing your module's JWT authentication information. For the claims, you can add a `module` claim indicating the human-readable name of your module. + + +### BLS Proposer Keys + +If requesting a signature directly from a proposer pubkey, use the following body specification: + +```json +{ + "type": "consensus", + "pubkey": "0x1234abcd...", + "object_root": "0x01020304..." +} +``` + +where: + +- `pubkey` is the 48-byte BLS public key, with optional `0x` prefix, of the proposer key that you want to request a signature from. +- `object_root` is the 32-byte data you want to sign, with optional `0x` prefix. + + +### BLS Proxy Keys + +If requesting a signature indirectly from a proposer key via a [proxy key](./commit-module.md#with-a-proxy-key), use the following body specification: + +```json +{ + "type": "proxy_bls", + "proxy": "0x1234abcd...", + "object_root": "0x01020304..." +} +``` + +where: + +- `proxy` is the 48-byte BLS public key, with optional `0x` prefix, of the proxy key that you want to request a signature from. +- `object_root` is the 32-byte data you want to sign, with optional `0x` prefix. + + +### ECDSA Proxy Keys + +**NOTE:** ECDSA proxy key support is not available when using Dirk. + +If requesting a signature indirectly from an Ethereum private key via a [proxy key](./commit-module.md#with-a-proxy-key), use the following body specification: + +```json +{ + "type": "proxy_ecdsa", + "proxy": "0x1234abcd...", + "object_root": "0x01020304..." +} +``` + +where: + +- `proxy` is the 20-byte Ethereum address of the proxy key, with optional `0x` prefix, of the ECDSA private key that you want to request a signature from. +- `object_root` is the 32-byte data you want to sign, with optional `0x` prefix. + + +### Response + +The response for any of the above will be one of the following, provided in plaintext format (not JSON). + + +#### `200 OK` + +A successful signing request, with the signature provided as a plaintext quoted hex-encoded string, with a `0x` prefix. For example, the response body would look like: +``` +"0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115" +``` + +#### `401 Unauthorized` + +Your module did not provide a JWT string in the request's authorization header, or the JWT string was not configured in the signer service's configuration file as belonging to your module. + + +#### `400 Bad Request` + +This can occur in several scenarios: + +- You requested an operation while using the Dirk signer mode instead of locally-managed signer mode, but Dirk doesn't support that operation. +- Something went wrong while preparing your request; the error text will provide more information. + + +#### `502 Bad Gateway` + +The signer service is running in Dirk signer mode, but Dirk could not be reached. + + +#### `404 Not Found` + +You either requested a route that doesn't exist, or you requested a signature from a key that does not exist. + + +#### `429 Too Many Requests` + +Your module attempted and failed JWT authentication too many times recently, and is currently timed out. It cannot make any more requests until the timeout ends. + + +#### `500 Internal Server Error` + +Your request was valid, but something went wrong internally that prevented it from being fulfilled. \ No newline at end of file diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 5dd46329..5f8bfd42 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -310,6 +310,7 @@ Delegation signatures will be stored in files with the format `/deleg A full example of a config file with Dirk can be found [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/configs/dirk_signer.toml). + ## Custom module We currently provide a test module that needs to be built locally. To build the module run: ```bash @@ -344,6 +345,7 @@ enabled = true id = "DA_COMMIT" type = "commit" docker_image = "test_da_commit" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" sleep_secs = 5 [[modules]] @@ -354,10 +356,11 @@ docker_image = "test_builder_log" A few things to note: - We now added a `signer` section which will be used to create the Signer module. -- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. Additional parameters needed for the business logic of the module will also be here, +- There is now a `[[modules]]` section which at a minimum needs to specify the module `id`, `type` and `docker_image`. For modules with type `commit`, which will be used to access the Signer service and request signatures for preconfs, you will also need to specify the module's unique `signing_id` (see ). Additional parameters needed for the business logic of the module will also be here. To learn more about developing modules, check out [here](/category/developing). + ## Vouch [Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS module is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. diff --git a/docs/docs/res/img/prop_commit_tree.png b/docs/docs/res/img/prop_commit_tree.png new file mode 100644 index 00000000..1e36f4b4 Binary files /dev/null and b/docs/docs/res/img/prop_commit_tree.png differ diff --git a/tests/Cargo.toml b/tests/Cargo.toml index ce273ae7..f1b5c9d9 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -9,9 +9,11 @@ alloy.workspace = true axum.workspace = true cb-common.workspace = true cb-pbs.workspace = true +cb-signer.workspace = true eyre.workspace = true reqwest.workspace = true serde_json.workspace = true +tempfile.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/tests/data/configs/signer.happy.toml b/tests/data/configs/signer.happy.toml new file mode 100644 index 00000000..6fb76445 --- /dev/null +++ b/tests/data/configs/signer.happy.toml @@ -0,0 +1,52 @@ +chain = "Hoodi" + +[pbs] +docker_image = "ghcr.io/commit-boost/pbs:latest" +with_signer = true +host = "127.0.0.1" +port = 18550 +relay_check = true +wait_all_registrations = true +timeout_get_header_ms = 950 +timeout_get_payload_ms = 4000 +timeout_register_validator_ms = 3000 +skip_sigverify = false +min_bid_eth = 0.5 +late_in_slot_time_ms = 2000 +extra_validation_enabled = false +rpc_url = "https://ethereum-holesky-rpc.publicnode.com" + +[[relays]] +id = "example-relay" +url = "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz" +headers = { X-MyCustomHeader = "MyCustomHeader" } +enable_timing_games = false +target_first_request_ms = 200 +frequency_get_header_ms = 300 + +[signer] +docker_image = "ghcr.io/commit-boost/signer:latest" +host = "127.0.0.1" +port = 20000 +jwt_auth_fail_limit = 3 +jwt_auth_fail_timeout_seconds = 300 + +[signer.local.loader] +key_path = "./tests/data/keys.example.json" + +[signer.local.store] +proxy_dir = "./proxies" + +[[modules]] +id = "test-module" +signing_id = "0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b" +type = "commit" +docker_image = "test_da_commit" +env_file = ".cb.env" + +[[modules]] +id = "another-module" +signing_id = "0x61fe00135d7b4912a8c63ada215ac2e62326e6e7b30f49a29fcf9779d7ad800d" +type = "commit" +docker_image = "test_da_commit" +env_file = ".cb.env" diff --git a/tests/src/lib.rs b/tests/src/lib.rs index a4fbbb6a..54aedc46 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -1,3 +1,4 @@ pub mod mock_relay; pub mod mock_validator; +pub mod signer_service; pub mod utils; diff --git a/tests/src/signer_service.rs b/tests/src/signer_service.rs new file mode 100644 index 00000000..c31e5a1c --- /dev/null +++ b/tests/src/signer_service.rs @@ -0,0 +1,71 @@ +use std::{collections::HashMap, time::Duration}; + +use alloy::{hex, primitives::FixedBytes}; +use cb_common::{ + commit::request::GetPubkeysResponse, + config::{ModuleSigningConfig, StartSignerConfig}, + signer::{SignerLoader, ValidatorKeysFormat}, + types::{Chain, ModuleId}, +}; +use cb_signer::service::SigningService; +use eyre::Result; +use reqwest::{Response, StatusCode}; +use tracing::info; + +use crate::utils::{get_signer_config, get_start_signer_config}; + +// Starts the signer moduler server on a separate task and returns its +// configuration +pub async fn start_server( + port: u16, + mod_signing_configs: &HashMap, +) -> Result { + let chain = Chain::Hoodi; + + // Create a signer config + let loader = SignerLoader::ValidatorsDir { + keys_path: "data/keystores/keys".into(), + secrets_path: "data/keystores/secrets".into(), + format: ValidatorKeysFormat::Lighthouse, + }; + let mut config = get_signer_config(loader); + config.port = port; + config.jwt_auth_fail_limit = 3; // Set a low fail limit for testing + config.jwt_auth_fail_timeout_seconds = 3; // Set a short timeout for testing + let start_config = get_start_signer_config(config, chain, mod_signing_configs); + + // Run the Signer + let server_handle = tokio::spawn(SigningService::run(start_config.clone())); + + // Make sure the server is running + tokio::time::sleep(Duration::from_millis(100)).await; + if server_handle.is_finished() { + return Err(eyre::eyre!( + "Signer service failed to start: {}", + server_handle.await.unwrap_err() + )); + } + Ok(start_config) +} + +// Verifies that the pubkeys returned by the server match the pubkeys in the +// test data +pub async fn verify_pubkeys(response: Response) -> Result<()> { + // Verify the expected pubkeys are returned + assert!(response.status() == StatusCode::OK); + let pubkey_json = response.json::().await?; + assert_eq!(pubkey_json.keys.len(), 2); + let expected_pubkeys = vec![ + FixedBytes::new(hex!("883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4")), + FixedBytes::new(hex!("b3a22e4a673ac7a153ab5b3c17a4dbef55f7e47210b20c0cbb0e66df5b36bb49ef808577610b034172e955d2312a61b9")), + ]; + for expected in expected_pubkeys { + assert!( + pubkey_json.keys.iter().any(|k| k.consensus == expected), + "Expected pubkey not found: {:?}", + expected + ); + info!("Server returned expected pubkey: {:?}", expected); + } + Ok(()) +} diff --git a/tests/src/utils.rs b/tests/src/utils.rs index f2ae9157..c2312a74 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -1,13 +1,25 @@ use std::{ + collections::HashMap, net::{Ipv4Addr, SocketAddr}, sync::{Arc, Once}, }; -use alloy::{primitives::U256, rpc::types::beacon::BlsPublicKey}; +use alloy::{ + primitives::{B256, U256}, + rpc::types::beacon::BlsPublicKey, +}; use cb_common::{ - config::{PbsConfig, PbsModuleConfig, RelayConfig}, + config::{ + CommitBoostConfig, LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, + PbsModuleConfig, RelayConfig, SignerConfig, SignerType, StartSignerConfig, + StaticModuleConfig, StaticPbsConfig, SIGNER_IMAGE_DEFAULT, + SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, + SIGNER_PORT_DEFAULT, + }, pbs::{RelayClient, RelayEntry}, - types::Chain, + signer::SignerLoader, + types::{Chain, ModuleId}, + utils::default_host, }; use eyre::Result; @@ -58,7 +70,7 @@ pub fn generate_mock_relay_with_batch_size( RelayClient::new(config) } -pub fn get_pbs_static_config(port: u16) -> PbsConfig { +pub fn get_pbs_config(port: u16) -> PbsConfig { PbsConfig { host: Ipv4Addr::UNSPECIFIED, port, @@ -75,6 +87,23 @@ pub fn get_pbs_static_config(port: u16) -> PbsConfig { } } +pub fn get_pbs_static_config(pbs_config: PbsConfig) -> StaticPbsConfig { + StaticPbsConfig { docker_image: String::from(""), pbs_config, with_signer: true } +} + +pub fn get_commit_boost_config(pbs_static_config: StaticPbsConfig) -> CommitBoostConfig { + CommitBoostConfig { + chain: Chain::Hoodi, + relays: vec![], + pbs: pbs_static_config, + muxes: None, + modules: Some(vec![]), + signer: None, + metrics: None, + logs: LogsSettings::default(), + } +} + pub fn to_pbs_config( chain: Chain, pbs_config: PbsConfig, @@ -91,3 +120,45 @@ pub fn to_pbs_config( muxes: None, } } + +pub fn get_signer_config(loader: SignerLoader) -> SignerConfig { + SignerConfig { + host: default_host(), + port: SIGNER_PORT_DEFAULT, + docker_image: SIGNER_IMAGE_DEFAULT.to_string(), + jwt_auth_fail_limit: SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, + jwt_auth_fail_timeout_seconds: SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, + inner: SignerType::Local { loader, store: None }, + } +} + +pub fn get_start_signer_config( + signer_config: SignerConfig, + chain: Chain, + mod_signing_configs: &HashMap, +) -> StartSignerConfig { + match signer_config.inner { + SignerType::Local { loader, .. } => StartSignerConfig { + chain, + loader: Some(loader), + store: None, + endpoint: SocketAddr::new(signer_config.host.into(), signer_config.port), + mod_signing_configs: mod_signing_configs.clone(), + jwt_auth_fail_limit: signer_config.jwt_auth_fail_limit, + jwt_auth_fail_timeout_seconds: signer_config.jwt_auth_fail_timeout_seconds, + dirk: None, + }, + _ => panic!("Only local signers are supported in tests"), + } +} + +pub fn create_module_config(id: &ModuleId, signing_id: &B256) -> StaticModuleConfig { + StaticModuleConfig { + id: id.clone(), + signing_id: Some(*signing_id), + docker_image: String::from(""), + env: None, + env_file: None, + kind: ModuleKind::Commit, + } +} diff --git a/tests/tests/config.rs b/tests/tests/config.rs index dafd96d9..f6f31d96 100644 --- a/tests/tests/config.rs +++ b/tests/tests/config.rs @@ -37,11 +37,11 @@ async fn test_load_pbs_happy() -> Result<()> { // Docker and general settings assert_eq!(config.pbs.docker_image, "ghcr.io/commit-boost/pbs:latest"); - assert_eq!(config.pbs.with_signer, false); + assert!(!config.pbs.with_signer); assert_eq!(config.pbs.pbs_config.host, "127.0.0.1".parse::().unwrap()); assert_eq!(config.pbs.pbs_config.port, 18550); - assert_eq!(config.pbs.pbs_config.relay_check, true); - assert_eq!(config.pbs.pbs_config.wait_all_registrations, true); + assert!(config.pbs.pbs_config.relay_check); + assert!(config.pbs.pbs_config.wait_all_registrations); // Timeouts assert_eq!(config.pbs.pbs_config.timeout_get_header_ms, 950); @@ -49,12 +49,12 @@ async fn test_load_pbs_happy() -> Result<()> { assert_eq!(config.pbs.pbs_config.timeout_register_validator_ms, 3000); // Bid settings and validation - assert_eq!(config.pbs.pbs_config.skip_sigverify, false); + assert!(!config.pbs.pbs_config.skip_sigverify); dbg!(&config.pbs.pbs_config.min_bid_wei); dbg!(&U256::from(0.5)); assert_eq!(config.pbs.pbs_config.min_bid_wei, U256::from((0.5 * WEI_PER_ETH as f64) as u64)); assert_eq!(config.pbs.pbs_config.late_in_slot_time_ms, 2000); - assert_eq!(config.pbs.pbs_config.extra_validation_enabled, false); + assert!(!config.pbs.pbs_config.extra_validation_enabled); assert_eq!( config.pbs.pbs_config.rpc_url, Some("https://ethereum-holesky-rpc.publicnode.com".parse::().unwrap()) @@ -64,7 +64,7 @@ async fn test_load_pbs_happy() -> Result<()> { let relay = &config.relays[0]; assert_eq!(relay.id, Some("example-relay".to_string())); assert_eq!(relay.entry.url, "http://0xa1cec75a3f0661e99299274182938151e8433c61a19222347ea1313d839229cb4ce4e3e5aa2bdeb71c8fcf1b084963c2@abc.xyz".parse::().unwrap()); - assert_eq!(relay.enable_timing_games, false); + assert!(!relay.enable_timing_games); assert_eq!(relay.target_first_request_ms, Some(200)); assert_eq!(relay.frequency_get_header_ms, Some(300)); diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 422a71a3..5a13b094 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -12,7 +12,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{start_mock_relay_service, MockRelayState}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -23,7 +23,7 @@ use tree_hash::TreeHash; async fn test_get_header() -> Result<()> { setup_test_env(); let signer = random_secret(); - let pubkey: BlsPublicKey = blst_pubkey_to_alloy(&signer.sk_to_pk()).into(); + let pubkey: BlsPublicKey = blst_pubkey_to_alloy(&signer.sk_to_pk()); let chain = Chain::Holesky; let pbs_port = 3200; @@ -35,7 +35,7 @@ async fn test_get_header() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -87,7 +87,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -119,7 +119,7 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index 0694b97a..7112a46b 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -9,7 +9,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{start_mock_relay_service, MockRelayState}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -34,7 +34,7 @@ async fn test_get_status() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_0_port)); tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -67,7 +67,7 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { // Don't start the relay // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays.clone()); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index a8f3ed1c..624217d3 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -10,7 +10,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{start_mock_relay_service, MockRelayState}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -37,7 +37,7 @@ async fn test_mux() -> Result<()> { // Register all relays in PBS config let relays = vec![default_relay.clone()]; - let mut config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let mut config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); config.all_relays = vec![mux_relay_1.clone(), mux_relay_2.clone(), default_relay.clone()]; // Configure mux for two relays diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index 3ab378a4..03c268ba 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -10,7 +10,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{start_mock_relay_service, MockRelayState}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -31,7 +31,7 @@ async fn test_submit_block() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -63,7 +63,7 @@ async fn test_submit_block_too_large() -> Result<()> { let mock_state = Arc::new(MockRelayState::new(chain, signer).with_large_body()); tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index c0a27c93..3f493305 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -10,7 +10,7 @@ use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ mock_relay::{start_mock_relay_service, MockRelayState}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, + utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, }; use eyre::Result; use reqwest::StatusCode; @@ -31,7 +31,7 @@ async fn test_register_validators() -> Result<()> { tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); @@ -77,7 +77,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs new file mode 100644 index 00000000..fce8ae72 --- /dev/null +++ b/tests/tests/signer_jwt_auth.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, time::Duration}; + +use alloy::primitives::b256; +use cb_common::{ + commit::constants::GET_PUBKEYS_PATH, + config::{load_module_signing_configs, ModuleSigningConfig}, + types::ModuleId, + utils::create_jwt, +}; +use cb_tests::{ + signer_service::{start_server, verify_pubkeys}, + utils::{self, setup_test_env}, +}; +use eyre::Result; +use reqwest::StatusCode; +use tracing::info; + +const JWT_MODULE: &str = "test-module"; + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id = ModuleId(JWT_MODULE.to_string()); + let signing_id = b256!("0101010101010101010101010101010101010101010101010101010101010101"); + + cfg.modules = Some(vec![utils::create_module_config(&module_id, &signing_id)]); + + let jwts = HashMap::from([(module_id.clone(), "supersecret".to_string())]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} + +#[tokio::test] +async fn test_signer_jwt_auth_success() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20100, &mod_cfgs).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Run a pubkeys request + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + let response = client.get(&url).bearer_auth(&jwt).send().await?; + + // Verify the expected pubkeys are returned + verify_pubkeys(response).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_signer_jwt_auth_fail() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20101, &mod_cfgs).await?; + + // Run a pubkeys request - this should fail due to invalid JWT + let jwt = create_jwt(&module_id, "incorrect secret")?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); + info!( + "Server returned expected error code {} for invalid JWT: {}", + response.status(), + response.text().await.unwrap_or_else(|_| "No response body".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn test_signer_jwt_rate_limit() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(JWT_MODULE.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20102, &mod_cfgs).await?; + let mod_cfg = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Run as many pubkeys requests as the fail limit + let jwt = create_jwt(&module_id, "incorrect secret")?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); + for _ in 0..start_config.jwt_auth_fail_limit { + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::UNAUTHORIZED); + } + + // Run another request - this should fail due to rate limiting now + let jwt = create_jwt(&module_id, &mod_cfg.jwt_secret)?; + let response = client.get(&url).bearer_auth(&jwt).send().await?; + assert!(response.status() == StatusCode::TOO_MANY_REQUESTS); + + // Wait for the rate limit timeout + tokio::time::sleep(Duration::from_secs(start_config.jwt_auth_fail_timeout_seconds as u64)) + .await; + + // Now the next request should succeed + let response = client.get(&url).bearer_auth(&jwt).send().await?; + verify_pubkeys(response).await?; + + Ok(()) +} diff --git a/tests/tests/signer_request_sig.rs b/tests/tests/signer_request_sig.rs new file mode 100644 index 00000000..1990172e --- /dev/null +++ b/tests/tests/signer_request_sig.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use alloy::{ + hex, + primitives::{b256, FixedBytes}, +}; +use cb_common::{ + commit::{ + constants::REQUEST_SIGNATURE_PATH, + request::{SignConsensusRequest, SignRequest}, + }, + config::{load_module_signing_configs, ModuleSigningConfig}, + types::ModuleId, + utils::create_jwt, +}; +use cb_tests::{ + signer_service::start_server, + utils::{self, setup_test_env}, +}; +use eyre::Result; +use reqwest::StatusCode; + +const MODULE_ID_1: &str = "test-module"; +const MODULE_ID_2: &str = "another-module"; +const PUBKEY_1: [u8; 48] = + hex!("883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4"); + +async fn create_mod_signing_configs() -> HashMap { + let mut cfg = + utils::get_commit_boost_config(utils::get_pbs_static_config(utils::get_pbs_config(0))); + + let module_id_1 = ModuleId(MODULE_ID_1.to_string()); + let signing_id_1 = b256!("0x6a33a23ef26a4836979edff86c493a69b26ccf0b4a16491a815a13787657431b"); + let module_id_2 = ModuleId(MODULE_ID_2.to_string()); + let signing_id_2 = b256!("0x61fe00135d7b4912a8c63ada215ac2e62326e6e7b30f49a29fcf9779d7ad800d"); + + cfg.modules = Some(vec![ + utils::create_module_config(&module_id_1, &signing_id_1), + utils::create_module_config(&module_id_2, &signing_id_2), + ]); + + let jwts = HashMap::from([ + (module_id_1.clone(), "supersecret".to_string()), + (module_id_2.clone(), "anothersecret".to_string()), + ]); + + load_module_signing_configs(&cfg, &jwts).unwrap() +} + +/// Makes sure the signer service signs requests correctly, using the module's +/// signing ID +#[tokio::test] +async fn test_signer_sign_request_good() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_1.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20200, &mod_cfgs).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for test module not found"); + + // Send a signing request + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let request = + SignRequest::Consensus(SignConsensusRequest { pubkey: FixedBytes(PUBKEY_1), object_root }); + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify the response is successful + assert!(response.status() == StatusCode::OK); + + // Verify the signature is returned + let signature = response.text().await?; + assert!(!signature.is_empty(), "Signature should not be empty"); + + let expected_signature = "\"0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115\""; + assert_eq!(signature, expected_signature, "Signature does not match expected value"); + + Ok(()) +} + +/// Makes sure the signer service returns a signature that is different for each +/// module +#[tokio::test] +async fn test_signer_sign_request_different_module() -> Result<()> { + setup_test_env(); + let module_id = ModuleId(MODULE_ID_2.to_string()); + let mod_cfgs = create_mod_signing_configs().await; + let start_config = start_server(20201, &mod_cfgs).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let request = + SignRequest::Consensus(SignConsensusRequest { pubkey: FixedBytes(PUBKEY_1), object_root }); + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify the response is successful + assert!(response.status() == StatusCode::OK); + + // Verify the signature is returned + let signature = response.text().await?; + assert!(!signature.is_empty(), "Signature should not be empty"); + + let incorrect_signature = "\"0xa43e623f009e615faa3987368f64d6286a4103de70e9a81d82562c50c91eae2d5d6fb9db9fe943aa8ee42fd92d8210c1149f25ed6aa72a557d74a0ed5646fdd0e8255ec58e3e2931695fe913863ba0cdf90d29f651bce0a34169a6f6ce5b3115\""; + assert_ne!(signature, incorrect_signature, "Signature does not match expected value"); + + Ok(()) +}