diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index 4453f597..fcc79b98 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -7,11 +7,11 @@ use std::{ use cb_common::{ config::{ load_optional_env_var, CommitBoostConfig, LogsSettings, ModuleKind, SignerConfig, - SignerType, BUILDER_PORT_ENV, BUILDER_URLS_ENV, CHAIN_SPEC_ENV, CONFIG_DEFAULT, CONFIG_ENV, - DIRK_CA_CERT_DEFAULT, DIRK_CA_CERT_ENV, DIRK_CERT_DEFAULT, DIRK_CERT_ENV, - DIRK_DIR_SECRETS_DEFAULT, DIRK_DIR_SECRETS_ENV, DIRK_KEY_DEFAULT, DIRK_KEY_ENV, JWTS_ENV, - LOGS_DIR_DEFAULT, LOGS_DIR_ENV, METRICS_PORT_ENV, MODULE_ID_ENV, MODULE_JWT_ENV, - PBS_ENDPOINT_ENV, PBS_MODULE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, + SignerType, ADMIN_JWT_ENV, BUILDER_PORT_ENV, BUILDER_URLS_ENV, CHAIN_SPEC_ENV, + CONFIG_DEFAULT, CONFIG_ENV, DIRK_CA_CERT_DEFAULT, DIRK_CA_CERT_ENV, DIRK_CERT_DEFAULT, + DIRK_CERT_ENV, DIRK_DIR_SECRETS_DEFAULT, DIRK_DIR_SECRETS_ENV, DIRK_KEY_DEFAULT, + DIRK_KEY_ENV, JWTS_ENV, LOGS_DIR_DEFAULT, LOGS_DIR_ENV, METRICS_PORT_ENV, MODULE_ID_ENV, + MODULE_JWT_ENV, PBS_ENDPOINT_ENV, PBS_MODULE_NAME, PROXY_DIR_DEFAULT, PROXY_DIR_ENV, PROXY_DIR_KEYS_DEFAULT, PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, PROXY_DIR_SECRETS_ENV, SIGNER_DEFAULT, SIGNER_DIR_KEYS_DEFAULT, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, SIGNER_DIR_SECRETS_ENV, SIGNER_JWT_SECRET_ENV, SIGNER_KEYS_ENV, @@ -334,6 +334,7 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re let mut signer_envs = IndexMap::from([ get_env_val(CONFIG_ENV, CONFIG_DEFAULT), get_env_same(JWTS_ENV), + get_env_same(ADMIN_JWT_ENV), get_env_uval(SIGNER_PORT_ENV, signer_port as u64), ]); @@ -360,6 +361,7 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re // write jwts to env envs.insert(JWTS_ENV.into(), format_comma_separated(&jwts)); + envs.insert(ADMIN_JWT_ENV.into(), random_jwt_secret()); // volumes let mut volumes = vec![config_volume.clone()]; diff --git a/crates/common/src/commit/constants.rs b/crates/common/src/commit/constants.rs index 7c9f948c..ea9cd9bb 100644 --- a/crates/common/src/commit/constants.rs +++ b/crates/common/src/commit/constants.rs @@ -3,3 +3,4 @@ pub const REQUEST_SIGNATURE_PATH: &str = "/signer/v1/request_signature"; pub const GENERATE_PROXY_KEY_PATH: &str = "/signer/v1/generate_proxy_key"; pub const STATUS_PATH: &str = "/status"; pub const RELOAD_PATH: &str = "/reload"; +pub const REVOKE_MODULE_PATH: &str = "/revoke_jwt"; diff --git a/crates/common/src/commit/request.rs b/crates/common/src/commit/request.rs index b8843234..9a67dcc2 100644 --- a/crates/common/src/commit/request.rs +++ b/crates/common/src/commit/request.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fmt::{self, Debug, Display}, str::FromStr, }; @@ -9,13 +10,17 @@ use alloy::{ rpc::types::beacon::BlsSignature, }; use derive_more::derive::From; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - constants::COMMIT_BOOST_DOMAIN, error::BlstErrorWrapper, signature::verify_signed_message, - signer::BlsPublicKey, types::Chain, + config::decode_string_to_map, + constants::COMMIT_BOOST_DOMAIN, + error::BlstErrorWrapper, + signature::verify_signed_message, + signer::BlsPublicKey, + types::{Chain, ModuleId}, }; pub trait ProxyId: AsRef<[u8]> + Debug + Clone + Copy + TreeHash + Display {} @@ -198,6 +203,31 @@ pub struct GetPubkeysResponse { pub keys: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReloadRequest { + #[serde(default, deserialize_with = "deserialize_jwt_secrets")] + pub jwt_secrets: Option>, + pub admin_secret: Option, +} + +pub fn deserialize_jwt_secrets<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let raw: String = Deserialize::deserialize(deserializer)?; + + decode_string_to_map(&raw) + .map(Some) + .map_err(|_| serde::de::Error::custom("Invalid format".to_string())) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RevokeModuleRequest { + pub module_id: ModuleId, +} + /// Map of consensus pubkeys to proxies #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ConsensusProxyMap { @@ -288,7 +318,7 @@ mod tests { let _: SignedProxyDelegationBls = serde_json::from_str(data).unwrap(); - let data = r#"{ + let data = r#"{ "message": { "delegator": "0xa3366b54f28e4bf1461926a3c70cdb0ec432b5c92554ecaae3742d33fb33873990cbed1761c68020e6d3c14d30a22050", "proxy": "0x4ca9939a8311a7cab3dde201b70157285fa81a9d" diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index 422af7e7..0cf9b3c3 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -37,6 +37,7 @@ pub const SIGNER_PORT_ENV: &str = "CB_SIGNER_PORT"; /// Comma separated list module_id=jwt_secret pub const JWTS_ENV: &str = "CB_JWTS"; +pub const ADMIN_JWT_ENV: &str = "CB_SIGNER_ADMIN_JWT"; /// The JWT secret for the signer to validate the modules requests pub const SIGNER_JWT_SECRET_ENV: &str = "CB_SIGNER_JWT_SECRET"; diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 9df6b948..ba0f0f87 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -89,6 +89,7 @@ pub struct StartSignerConfig { pub store: Option, pub server_port: u16, pub jwts: HashMap, + pub admin_secret: String, pub dirk: Option, } @@ -96,7 +97,7 @@ impl StartSignerConfig { pub fn load_from_env() -> Result { let config = CommitBoostConfig::from_env_path()?; - let jwts = load_jwt_secrets()?; + let (admin_secret, jwts) = load_jwt_secrets()?; let server_port = load_env_var(SIGNER_PORT_ENV)?.parse()?; let signer = config.signer.ok_or_eyre("Signer config is missing")?.inner; @@ -107,6 +108,7 @@ impl StartSignerConfig { loader: Some(loader), server_port, jwts, + admin_secret, store, dirk: None, }), @@ -135,6 +137,7 @@ impl StartSignerConfig { chain: config.chain, server_port, jwts, + admin_secret, loader: None, store, dirk: Some(DirkConfig { diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 67c367c5..5962500b 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, path::Path}; use eyre::{bail, Context, Result}; use serde::de::DeserializeOwned; -use super::JWTS_ENV; +use super::{ADMIN_JWT_ENV, JWTS_ENV}; use crate::types::ModuleId; pub fn load_env_var(env: &str) -> Result { @@ -25,12 +25,13 @@ pub fn load_file_from_env(env: &str) -> Result { } /// Loads a map of module id -> jwt secret from a json env -pub fn load_jwt_secrets() -> Result> { +pub fn load_jwt_secrets() -> Result<(String, HashMap)> { + let admin_jwt = std::env::var(ADMIN_JWT_ENV).wrap_err(format!("{ADMIN_JWT_ENV} is not set"))?; let jwt_secrets = std::env::var(JWTS_ENV).wrap_err(format!("{JWTS_ENV} is not set"))?; - decode_string_to_map(&jwt_secrets) + decode_string_to_map(&jwt_secrets).map(|secrets| (admin_jwt, secrets)) } -fn decode_string_to_map(raw: &str) -> Result> { +pub fn decode_string_to_map(raw: &str) -> Result> { // trim the string and split for comma raw.trim() .split(',') diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 5293a789..3d07e89c 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -23,6 +23,12 @@ pub struct JwtClaims { pub module: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtAdmin { + pub exp: u64, + pub admin: bool, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Chain { Mainnet, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 37119580..4f40d09b 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -26,7 +26,7 @@ use crate::{ config::LogsSettings, constants::SIGNER_JWT_EXPIRATION, pbs::HEADER_VERSION_VALUE, - types::{Chain, Jwt, JwtClaims, ModuleId}, + types::{Chain, Jwt, JwtAdmin, JwtClaims, ModuleId}, }; const MILLIS_PER_SECOND: u64 = 1_000; @@ -320,6 +320,24 @@ pub fn validate_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { .map_err(From::from) } +/// Validate an admin JWT with the given secret +pub fn validate_admin_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { + let mut validation = jsonwebtoken::Validation::default(); + validation.leeway = 10; + + let token = jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), + &validation, + )?; + + if token.claims.admin { + Ok(()) + } else { + eyre::bail!("Token is not admin") + } +} + /// Generates a random string pub fn random_jwt_secret() -> String { rand::rng().sample_iter(&Alphanumeric).take(32).map(char::from).collect() diff --git a/crates/signer/src/error.rs b/crates/signer/src/error.rs index 477e9e42..db319542 100644 --- a/crates/signer/src/error.rs +++ b/crates/signer/src/error.rs @@ -25,6 +25,9 @@ pub enum SignerModuleError { #[error("Dirk signer does not support this operation")] DirkNotSupported, + #[error("module id not found")] + ModuleIdNotFound, + #[error("internal error: {0}")] Internal(String), } @@ -45,6 +48,7 @@ impl IntoResponse for SignerModuleError { (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string()) } SignerModuleError::SignerError(err) => (StatusCode::BAD_REQUEST, err.to_string()), + SignerModuleError::ModuleIdNotFound => (StatusCode::NOT_FOUND, self.to_string()), } .into_response() } diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 28a1d934..36df7f69 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -13,17 +13,17 @@ use cb_common::{ commit::{ constants::{ GENERATE_PROXY_KEY_PATH, GET_PUBKEYS_PATH, RELOAD_PATH, REQUEST_SIGNATURE_PATH, - STATUS_PATH, + REVOKE_MODULE_PATH, STATUS_PATH, }, request::{ - EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, SignConsensusRequest, - SignProxyRequest, SignRequest, + EncryptionScheme, GenerateProxyRequest, GetPubkeysResponse, ReloadRequest, + RevokeModuleRequest, SignConsensusRequest, SignProxyRequest, SignRequest, }, }, config::StartSignerConfig, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, types::{Chain, Jwt, ModuleId}, - utils::{decode_jwt, validate_jwt}, + utils::{decode_jwt, validate_admin_jwt, validate_jwt}, }; use cb_metrics::provider::MetricsProvider; use eyre::Context; @@ -47,7 +47,9 @@ struct SigningState { manager: Arc>, /// Map of modules ids to JWT secrets. This also acts as registry of all /// modules running - jwts: Arc>, + jwts: Arc>>, + /// Secret for the admin JWT + admin_secret: Arc>, } impl SigningService { @@ -61,7 +63,8 @@ impl SigningService { let state = SigningState { manager: Arc::new(RwLock::new(start_manager(config.clone()).await?)), - jwts: config.jwts.into(), + jwts: Arc::new(RwLock::new(config.jwts)), + admin_secret: Arc::new(RwLock::new(config.admin_secret)), }; let loaded_consensus = state.manager.read().await.available_consensus_signers(); @@ -71,20 +74,25 @@ impl SigningService { SigningService::init_metrics(config.chain)?; - let app = axum::Router::new() + let signer_app = axum::Router::new() .route(REQUEST_SIGNATURE_PATH, post(handle_request_signature)) .route(GET_PUBKEYS_PATH, get(handle_get_pubkeys)) .route(GENERATE_PROXY_KEY_PATH, post(handle_generate_proxy)) .route_layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) + .with_state(state.clone()) + .route_layer(middleware::from_fn(log_request)); + + let admin_app = axum::Router::new() .route(RELOAD_PATH, post(handle_reload)) + .route(REVOKE_MODULE_PATH, post(handle_revoke_module)) + .route_layer(middleware::from_fn_with_state(state.clone(), admin_auth)) .with_state(state.clone()) .route_layer(middleware::from_fn(log_request)) .route(STATUS_PATH, get(handle_status)); - let address = SocketAddr::from(([0, 0, 0, 0], config.server_port)); let listener = TcpListener::bind(address).await?; - axum::serve(listener, app).await.wrap_err("signer server exited") + axum::serve(listener, signer_app.merge(admin_app)).await.wrap_err("signer server exited") } fn init_metrics(network: Chain) -> eyre::Result<()> { @@ -108,7 +116,8 @@ async fn jwt_auth( SignerModuleError::Unauthorized })?; - let jwt_secret = state.jwts.get(&module_id).ok_or_else(|| { + let guard = state.jwts.read().await; + let jwt_secret = guard.get(&module_id).ok_or_else(|| { error!("Unauthorized request. Was the module started correctly?"); SignerModuleError::Unauthorized })?; @@ -123,6 +132,22 @@ async fn jwt_auth( Ok(next.run(req).await) } +async fn admin_auth( + State(state): State, + TypedHeader(auth): TypedHeader>, + req: Request, + next: Next, +) -> Result { + let jwt: Jwt = auth.token().to_string().into(); + + validate_admin_jwt(jwt, &state.admin_secret.read().await).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + + Ok(next.run(req).await) +} + /// Requests logging middleware layer async fn log_request(req: Request, next: Next) -> Result { let url = &req.uri().clone(); @@ -257,6 +282,7 @@ async fn handle_generate_proxy( async fn handle_reload( State(mut state): State, + Json(request): Json, ) -> Result { let req_id = Uuid::new_v4(); @@ -270,6 +296,14 @@ async fn handle_reload( } }; + if let Some(jwt_secrets) = request.jwt_secrets { + *state.jwts.write().await = jwt_secrets; + } + + if let Some(admin_secret) = request.admin_secret { + *state.admin_secret.write().await = admin_secret; + } + let new_manager = match start_manager(config).await { Ok(manager) => manager, Err(err) => { @@ -283,6 +317,17 @@ async fn handle_reload( Ok(StatusCode::OK) } +async fn handle_revoke_module( + State(state): State, + Json(request): Json, +) -> Result { + let mut guard = state.jwts.write().await; + guard + .remove(&request.module_id) + .ok_or(SignerModuleError::ModuleIdNotFound) + .map(|_| StatusCode::OK) +} + async fn start_manager(config: StartSignerConfig) -> eyre::Result { let proxy_store = if let Some(store) = config.store.clone() { Some(store.init_from_env()?) diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 4e642205..ebdbef94 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -365,6 +365,15 @@ Commit-Boost supports hot-reloading the configuration file. This means that you docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST http://localhost:20000/reload ``` +### Signer module reload + +The signer module takes 2 optional parameters in the JSON body: + +- `jwt_secrets`: a string with a comma-separated list of `=` for all modules. +- `admin_secret`: a string with the secret for the signer admin JWT. + +In the case that someone of those isn't present, that parameter won't be updated. + ### Notes - The hot reload feature is available for PBS modules (both default and custom) and signer module. diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index 3708ab19..37110714 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -27,6 +27,7 @@ Modules need some environment variables to work correctly. ### Signer Module - `CB_SIGNER_JWT_SECRET`: secret to use for JWT authentication with the Signer module. +- `CB_SIGNER_ADMIN_JWT`: secret to use for admin JWT. - `CB_SIGNER_PORT`: required, port to open the signer server on. - For loading keys we currently support: - `CB_SIGNER_LOADER_FILE`: path to a `.json` with plaintext keys (for testing purposes only).