diff --git a/Cargo.lock b/Cargo.lock index 7db6ff67..5ebc811a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1493,6 +1493,7 @@ dependencies = [ "ethereum_ssz 0.8.3", "ethereum_ssz_derive", "eyre", + "jsonwebtoken", "pbkdf2 0.12.2", "rand 0.9.0", "reqwest", @@ -1566,6 +1567,7 @@ dependencies = [ "eyre", "futures", "headers", + "jsonwebtoken", "lazy_static", "prometheus", "prost", @@ -2574,8 +2576,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -3167,6 +3171,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "ring 0.17.14", + "serde", + "serde_json", +] + [[package]] name = "k256" version = "0.13.4" diff --git a/Cargo.toml b/Cargo.toml index 43bf3991..aef26a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,3 +70,4 @@ typenum = "1.17.0" unicode-normalization = "0.1.24" url = { version = "2.5.0", features = ["serde"] } uuid = { version = "1.8.0", features = ["fast-rng", "serde", "v4"] } +jsonwebtoken = { version = "9.3.1", default-features = false } diff --git a/config.example.toml b/config.example.toml index e5e7f777..a832a945 100644 --- a/config.example.toml +++ b/config.example.toml @@ -141,9 +141,9 @@ 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] # Docker image to use for the Signer module. # OPTIONAL, DEFAULT: ghcr.io/commit-boost/signer:latest -# [signer] # docker_image = "ghcr.io/commit-boost/signer:latest" # For Remote signer: # [signer.remote] diff --git a/crates/cli/src/docker_init.rs b/crates/cli/src/docker_init.rs index 768c45b6..4453f597 100644 --- a/crates/cli/src/docker_init.rs +++ b/crates/cli/src/docker_init.rs @@ -6,21 +6,21 @@ use std::{ use cb_common::{ config::{ - 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, PROXY_DIR_KEYS_DEFAULT, - PROXY_DIR_KEYS_ENV, PROXY_DIR_SECRETS_DEFAULT, PROXY_DIR_SECRETS_ENV, SIGNER_DEFAULT, - SIGNER_DIR_KEYS_DEFAULT, SIGNER_DIR_KEYS_ENV, SIGNER_DIR_SECRETS_DEFAULT, - SIGNER_DIR_SECRETS_ENV, SIGNER_KEYS_ENV, SIGNER_MODULE_NAME, SIGNER_PORT_ENV, - SIGNER_URL_ENV, + 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, + 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, + SIGNER_MODULE_NAME, SIGNER_PORT_ENV, SIGNER_URL_ENV, }, pbs::{BUILDER_API_PATH, GET_STATUS_PATH}, signer::{ProxyStore, SignerLoader}, types::ModuleId, - utils::random_jwt, + utils::random_jwt_secret, }; use docker_compose_types::{ Compose, DependsCondition, DependsOnOptions, EnvFile, Environment, Healthcheck, @@ -86,7 +86,10 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re let mut warnings = Vec::new(); - let mut needs_signer_module = cb_config.pbs.with_signer; + let needs_signer_module = cb_config.pbs.with_signer || + cb_config.modules.as_ref().is_some_and(|modules| { + modules.iter().any(|module| matches!(module.kind, ModuleKind::Commit)) + }); // setup modules if let Some(modules_config) = cb_config.modules { @@ -97,9 +100,9 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re // a commit module needs a JWT and access to the signer network ModuleKind::Commit => { let mut ports = vec![]; - needs_signer_module = true; - let jwt = random_jwt(); + let jwt_secret = load_optional_env_var(SIGNER_JWT_SECRET_ENV) + .unwrap_or_else(random_jwt_secret); let jwt_name = format!("CB_JWT_{}", module.id.to_uppercase()); // module ids are assumed unique, so envs dont override each other @@ -146,8 +149,8 @@ pub async fn handle_docker_init(config_path: PathBuf, output_dir: PathBuf) -> Re module_envs.insert(key, val); } - envs.insert(jwt_name.clone(), jwt.clone()); - jwts.insert(module.id.clone(), jwt); + envs.insert(jwt_name.clone(), jwt_secret.clone()); + jwts.insert(module.id.clone(), jwt_secret); // networks let module_networks = vec![SIGNER_NETWORK.to_owned()]; diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 6f5dd36d..df78b046 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -40,3 +40,4 @@ tree_hash.workspace = true tree_hash_derive.workspace = true unicode-normalization.workspace = true url.workspace = true +jsonwebtoken.workspace = true diff --git a/crates/common/src/commit/client.rs b/crates/common/src/commit/client.rs index 8f1a4e3b..34413b65 100644 --- a/crates/common/src/commit/client.rs +++ b/crates/common/src/commit/client.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::time::{Duration, Instant}; use alloy::{primitives::Address, rpc::types::beacon::BlsSignature}; use eyre::WrapErr; @@ -15,7 +15,10 @@ use super::{ }, }; use crate::{ + constants::SIGNER_JWT_EXPIRATION, signer::{BlsPublicKey, EcdsaSignature}, + types::{Jwt, ModuleId}, + utils::create_jwt, DEFAULT_REQUEST_TIMEOUT, }; @@ -23,31 +26,65 @@ use crate::{ #[derive(Debug, Clone)] pub struct SignerClient { /// Url endpoint of the Signer Module - url: Arc, + url: Url, client: reqwest::Client, + last_jwt_refresh: Instant, + module_id: ModuleId, + jwt_secret: Jwt, } impl SignerClient { /// Create a new SignerClient - pub fn new(signer_server_url: Url, jwt: &str) -> eyre::Result { - let mut headers = HeaderMap::new(); + pub fn new(signer_server_url: Url, jwt_secret: Jwt, module_id: ModuleId) -> eyre::Result { + let jwt = create_jwt(&module_id, &jwt_secret)?; let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", jwt)).wrap_err("invalid jwt")?; auth_value.set_sensitive(true); + + let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, auth_value); + let client = reqwest::Client::builder() .timeout(DEFAULT_REQUEST_TIMEOUT) .default_headers(headers) .build()?; - Ok(Self { url: signer_server_url.into(), client }) + Ok(Self { + url: signer_server_url, + client, + last_jwt_refresh: Instant::now(), + module_id, + jwt_secret, + }) + } + + fn refresh_jwt(&mut self) -> Result<(), SignerClientError> { + if self.last_jwt_refresh.elapsed() > Duration::from_secs(SIGNER_JWT_EXPIRATION) { + let jwt = create_jwt(&self.module_id, &self.jwt_secret)?; + + let mut auth_value = + HeaderValue::from_str(&format!("Bearer {}", jwt)).wrap_err("invalid jwt")?; + auth_value.set_sensitive(true); + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth_value); + + self.client = reqwest::Client::builder() + .timeout(DEFAULT_REQUEST_TIMEOUT) + .default_headers(headers) + .build()?; + } + + Ok(()) } /// Request a list of validator pubkeys for which signatures can be /// requested. // TODO: add more docs on how proxy keys work - pub async fn get_pubkeys(&self) -> Result { + pub async fn get_pubkeys(&mut self) -> Result { + self.refresh_jwt()?; + let url = self.url.join(GET_PUBKEYS_PATH)?; let res = self.client.get(url).send().await?; @@ -62,10 +99,12 @@ impl SignerClient { } /// Send a signature request - async fn request_signature(&self, request: &SignRequest) -> Result + async fn request_signature(&mut self, request: &SignRequest) -> Result where T: for<'de> Deserialize<'de>, { + self.refresh_jwt()?; + let url = self.url.join(REQUEST_SIGNATURE_PATH)?; let res = self.client.post(url).json(&request).send().await?; @@ -85,33 +124,35 @@ impl SignerClient { } pub async fn request_consensus_signature( - &self, + &mut self, request: SignConsensusRequest, ) -> Result { self.request_signature(&request.into()).await } pub async fn request_proxy_signature_ecdsa( - &self, + &mut self, request: SignProxyRequest
, ) -> Result { self.request_signature(&request.into()).await } pub async fn request_proxy_signature_bls( - &self, + &mut self, request: SignProxyRequest, ) -> Result { self.request_signature(&request.into()).await } async fn generate_proxy_key( - &self, + &mut self, request: &GenerateProxyRequest, ) -> Result, SignerClientError> where T: ProxyId + for<'de> Deserialize<'de>, { + self.refresh_jwt()?; + let url = self.url.join(GENERATE_PROXY_KEY_PATH)?; let res = self.client.post(url).json(&request).send().await?; @@ -131,7 +172,7 @@ impl SignerClient { } pub async fn generate_proxy_key_bls( - &self, + &mut self, consensus_pubkey: BlsPublicKey, ) -> Result, SignerClientError> { let request = GenerateProxyRequest::new(consensus_pubkey, EncryptionScheme::Bls); @@ -142,7 +183,7 @@ impl SignerClient { } pub async fn generate_proxy_key_ecdsa( - &self, + &mut self, consensus_pubkey: BlsPublicKey, ) -> Result, SignerClientError> { let request = GenerateProxyRequest::new(consensus_pubkey, EncryptionScheme::Ecdsa); diff --git a/crates/common/src/commit/error.rs b/crates/common/src/commit/error.rs index 94a15aad..47a9f397 100644 --- a/crates/common/src/commit/error.rs +++ b/crates/common/src/commit/error.rs @@ -14,4 +14,7 @@ pub enum SignerClientError { #[error("url parse error: {0}")] ParseError(#[from] url::ParseError), + + #[error("JWT error: {0}")] + JWTError(#[from] eyre::Error), } diff --git a/crates/common/src/config/constants.rs b/crates/common/src/config/constants.rs index 31580cd8..422af7e7 100644 --- a/crates/common/src/config/constants.rs +++ b/crates/common/src/config/constants.rs @@ -37,6 +37,8 @@ pub const SIGNER_PORT_ENV: &str = "CB_SIGNER_PORT"; /// Comma separated list module_id=jwt_secret pub const JWTS_ENV: &str = "CB_JWTS"; +/// The JWT secret for the signer to validate the modules requests +pub const SIGNER_JWT_SECRET_ENV: &str = "CB_SIGNER_JWT_SECRET"; /// Path to json file with plaintext keys (testing only) pub const SIGNER_KEYS_ENV: &str = "CB_SIGNER_LOADER_FILE"; diff --git a/crates/common/src/config/module.rs b/crates/common/src/config/module.rs index 2476079b..16b089ca 100644 --- a/crates/common/src/config/module.rs +++ b/crates/common/src/config/module.rs @@ -104,7 +104,7 @@ pub fn load_commit_module_config() -> Result() -> Result<(PbsModuleC let signer_client = if cb_config.pbs.static_config.with_signer { // if custom pbs requires a signer client, load jwt - let module_jwt = load_env_var(MODULE_JWT_ENV)?; + let module_jwt = Jwt(load_env_var(MODULE_JWT_ENV)?); let signer_server_url = load_env_var(SIGNER_URL_ENV)?.parse()?; - Some(SignerClient::new(signer_server_url, &module_jwt)?) + Some(SignerClient::new( + signer_server_url, + module_jwt, + ModuleId(PBS_MODULE_NAME.to_string()), + )?) } else { None }; diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 3d1dccc9..9df6b948 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -1,20 +1,18 @@ -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; -use bimap::BiHashMap; use eyre::{bail, OptionExt, Result}; use serde::{Deserialize, Serialize}; use tonic::transport::{Certificate, Identity}; use url::Url; use super::{ - constants::SIGNER_IMAGE_DEFAULT, - utils::{load_env_var, load_jwts}, - CommitBoostConfig, SIGNER_PORT_ENV, + constants::SIGNER_IMAGE_DEFAULT, load_jwt_secrets, utils::load_env_var, CommitBoostConfig, + SIGNER_PORT_ENV, }; use crate::{ config::{DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV}, signer::{ProxyStore, SignerLoader}, - types::{Chain, Jwt, ModuleId}, + types::{Chain, ModuleId}, }; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -90,7 +88,7 @@ pub struct StartSignerConfig { pub loader: Option, pub store: Option, pub server_port: u16, - pub jwts: BiHashMap, + pub jwts: HashMap, pub dirk: Option, } @@ -98,7 +96,7 @@ impl StartSignerConfig { pub fn load_from_env() -> Result { let config = CommitBoostConfig::from_env_path()?; - let jwts = load_jwts()?; + let 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; diff --git a/crates/common/src/config/utils.rs b/crates/common/src/config/utils.rs index 538bee83..67c367c5 100644 --- a/crates/common/src/config/utils.rs +++ b/crates/common/src/config/utils.rs @@ -1,11 +1,10 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; -use bimap::BiHashMap; -use eyre::{bail, Context, Ok, Result}; +use eyre::{bail, Context, Result}; use serde::de::DeserializeOwned; -use super::constants::JWTS_ENV; -use crate::types::{Jwt, ModuleId}; +use super::JWTS_ENV; +use crate::types::ModuleId; pub fn load_env_var(env: &str) -> Result { std::env::var(env).wrap_err(format!("{env} is not set")) @@ -25,20 +24,20 @@ pub fn load_file_from_env(env: &str) -> Result { load_from_file(&path) } -/// Loads a bidirectional map of module id <-> jwt token from a json env -pub fn load_jwts() -> Result> { - let jwts = std::env::var(JWTS_ENV).wrap_err(format!("{JWTS_ENV} is not set"))?; - decode_string_to_map(&jwts) +/// Loads a map of module id -> jwt secret from a json env +pub fn load_jwt_secrets() -> Result> { + let jwt_secrets = std::env::var(JWTS_ENV).wrap_err(format!("{JWTS_ENV} is not set"))?; + decode_string_to_map(&jwt_secrets) } -fn decode_string_to_map(raw: &str) -> Result> { +fn decode_string_to_map(raw: &str) -> Result> { // trim the string and split for comma raw.trim() .split(',') .map(|pair| { let mut parts = pair.trim().split('='); match (parts.next(), parts.next()) { - (Some(key), Some(value)) => Ok((ModuleId(key.into()), Jwt(value.into()))), + (Some(key), Some(value)) => Ok((ModuleId(key.into()), value.into())), _ => bail!("Invalid key-value pair: {pair}"), } }) @@ -55,7 +54,7 @@ mod tests { let map = decode_string_to_map(raw).unwrap(); - assert_eq!(map.get_by_left(&ModuleId("KEY".into())), Some(&Jwt("VALUE".into()))); - assert_eq!(map.get_by_left(&ModuleId("KEY2".into())), Some(&Jwt("value2".into()))); + assert_eq!(map.get(&ModuleId("KEY".into())), Some(&"VALUE".to_string())); + assert_eq!(map.get(&ModuleId("KEY2".into())), Some(&"value2".to_string())); } } diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index d03904b3..c075ed13 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -3,3 +3,4 @@ pub const GENESIS_VALIDATORS_ROOT: [u8; 32] = [0; 32]; pub const COMMIT_BOOST_DOMAIN: [u8; 4] = [109, 109, 111, 67]; pub const COMMIT_BOOST_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const COMMIT_BOOST_COMMIT: &str = env!("GIT_HASH"); +pub const SIGNER_JWT_EXPIRATION: u64 = 300; // 5 minutes diff --git a/crates/common/src/signer/store.rs b/crates/common/src/signer/store.rs index 822c0369..d6353f4d 100644 --- a/crates/common/src/signer/store.rs +++ b/crates/common/src/signer/store.rs @@ -19,7 +19,7 @@ use eth2_keystore::{ }, Uuid, IV_SIZE, SALT_SIZE, }; -use eyre::OptionExt; +use eyre::{Context, OptionExt}; use rand::Rng; use serde::{Deserialize, Serialize}; use tracing::{trace, warn}; @@ -195,7 +195,9 @@ impl ProxyStore { let mut ecdsa_map: HashMap> = HashMap::new(); // Iterate over the entries in the base directory - for entry in std::fs::read_dir(proxy_dir)? { + for entry in std::fs::read_dir(proxy_dir) + .wrap_err_with(|| format!("failed reading proxy dir: {proxy_dir:?}"))? + { let entry = entry?; let module_path = entry.path(); diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index b7d3e01a..5293a789 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -17,6 +17,12 @@ pub struct ModuleId(pub String); #[serde(transparent)] pub struct Jwt(pub String); +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtClaims { + pub exp: u64, + pub module: String, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Chain { Mainnet, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 108690a2..37119580 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -22,7 +22,12 @@ use tracing_subscriber::{ EnvFilter, }; -use crate::{config::LogsSettings, pbs::HEADER_VERSION_VALUE, types::Chain}; +use crate::{ + config::LogsSettings, + constants::SIGNER_JWT_EXPIRATION, + pbs::HEADER_VERSION_VALUE, + types::{Chain, Jwt, JwtClaims, ModuleId}, +}; const MILLIS_PER_SECOND: u64 = 1_000; @@ -246,11 +251,11 @@ fn format_crates_filter(default_level: &str, crates_level: &str) -> EnvFilter { pub fn print_logo() { println!( - r#" ______ _ __ ____ __ + r#" ______ _ __ ____ __ / ____/___ ____ ___ ____ ___ (_) /_ / __ )____ ____ _____/ /_ / / / __ \/ __ `__ \/ __ `__ \/ / __/ / __ / __ \/ __ \/ ___/ __/ -/ /___/ /_/ / / / / / / / / / / / / /_ / /_/ / /_/ / /_/ (__ ) /_ -\____/\____/_/ /_/ /_/_/ /_/ /_/_/\__/ /_____/\____/\____/____/\__/ +/ /___/ /_/ / / / / / / / / / / / / /_ / /_/ / /_/ / /_/ (__ ) /_ +\____/\____/_/ /_/ /_/_/ /_/ /_/_/\__/ /_____/\____/\____/____/\__/ "# ) } @@ -269,8 +274,54 @@ pub fn blst_pubkey_to_alloy(pubkey: &PublicKey) -> BlsPublicKey { BlsPublicKey::from_slice(&pubkey.to_bytes()) } +/// Create a JWT for the given module id with expiration +pub fn create_jwt(module_id: &ModuleId, secret: &str) -> eyre::Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &JwtClaims { + module: module_id.to_string(), + exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + }, + &jsonwebtoken::EncodingKey::from_secret(secret.as_ref()), + ) + .map_err(Into::into) + .map(Jwt::from) +} + +/// Decode a JWT and return the module id. IMPORTANT: This function does not +/// validate the JWT, it only obtains the module id from the claims. +pub fn decode_jwt(jwt: Jwt) -> eyre::Result { + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let module = jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(&[]), + &validation, + )? + .claims + .module + .into(); + + Ok(module) +} + +/// Validate a JWT with the given secret +pub fn validate_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { + let mut validation = jsonwebtoken::Validation::default(); + validation.leeway = 10; + + jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), + &validation, + ) + .map(|_| ()) + .map_err(From::from) +} + /// Generates a random string -pub fn random_jwt() -> String { +pub fn random_jwt_secret() -> String { rand::rng().sample_iter(&Alphanumeric).take(32).map(char::from).collect() } @@ -309,3 +360,31 @@ pub async fn wait_for_signal() -> eyre::Result<()> { tokio::signal::ctrl_c().await?; Ok(()) } + +#[cfg(test)] +mod test { + use super::{create_jwt, decode_jwt, validate_jwt}; + use crate::types::{Jwt, ModuleId}; + + #[test] + fn test_jwt_validation() { + // Check valid JWT + let jwt = create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret").unwrap(); + let module_id = decode_jwt(jwt.clone()).unwrap(); + assert_eq!(module_id, ModuleId("DA_COMMIT".to_string())); + let response = validate_jwt(jwt, "secret".as_ref()); + assert!(response.is_ok()); + + // Check expired JWT + let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDI5OTU5NDYsIm1vZHVsZSI6IkRBX0NPTU1JVCJ9.iiq4Z2ed2hk3c3c-cn2QOQJWE5XUOc5BoaIPT-I8q-s".to_string()); + let response = validate_jwt(expired_jwt, "secret"); + assert!(response.is_err()); + assert_eq!(response.unwrap_err().to_string(), "ExpiredSignature"); + + // Check invalid signature JWT + let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NDI5OTU5NDYsIm1vZHVsZSI6IkRBX0NPTU1JVCJ9.w9WYdDNzgDjYTvjBkk4GGzywGNBYPxnzU2uJWzPUT1s".to_string()); + let response = validate_jwt(invalid_jwt, "secret"); + assert!(response.is_err()); + assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); + } +} diff --git a/crates/signer/Cargo.toml b/crates/signer/Cargo.toml index 2b108bd2..69f92886 100644 --- a/crates/signer/Cargo.toml +++ b/crates/signer/Cargo.toml @@ -26,6 +26,7 @@ tonic.workspace = true tracing.workspace = true tree_hash.workspace = true uuid.workspace = true +jsonwebtoken.workspace = true [build-dependencies] tonic-build.workspace = true diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 99f8e09e..28a1d934 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{collections::HashMap, net::SocketAddr, sync::Arc}; use axum::{ extract::{Request, State}, @@ -9,7 +9,6 @@ use axum::{ Extension, Json, }; use axum_extra::TypedHeader; -use bimap::BiHashMap; use cb_common::{ commit::{ constants::{ @@ -24,6 +23,7 @@ use cb_common::{ config::StartSignerConfig, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, types::{Chain, Jwt, ModuleId}, + utils::{decode_jwt, validate_jwt}, }; use cb_metrics::provider::MetricsProvider; use eyre::Context; @@ -45,9 +45,9 @@ pub struct SigningService; struct SigningState { /// Manager handling different signing methods manager: Arc>, - /// Map of JWTs to module ids. This also acts as registry of all modules - /// running - jwts: Arc>, + /// Map of modules ids to JWT secrets. This also acts as registry of all + /// modules running + jwts: Arc>, } impl SigningService { @@ -57,7 +57,7 @@ impl SigningService { return Ok(()); } - let module_ids: Vec = config.jwts.left_values().cloned().map(Into::into).collect(); + let module_ids: Vec = config.jwts.keys().cloned().map(Into::into).collect(); let state = SigningState { manager: Arc::new(RwLock::new(start_manager(config.clone()).await?)), @@ -78,15 +78,13 @@ impl SigningService { .route_layer(middleware::from_fn_with_state(state.clone(), jwt_auth)) .route(RELOAD_PATH, post(handle_reload)) .with_state(state.clone()) - .route_layer(middleware::from_fn(log_request)); - let status_router = axum::Router::new().route(STATUS_PATH, get(handle_status)); + .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, axum::Router::new().merge(app).merge(status_router)) - .await - .wrap_err("signer server exited") + axum::serve(listener, app).await.wrap_err("signer server exited") } fn init_metrics(network: Chain) -> eyre::Result<()> { @@ -103,12 +101,24 @@ async fn jwt_auth( ) -> Result { let jwt: Jwt = auth.token().to_string().into(); - let module_id = state.jwts.get_by_right(&jwt).ok_or_else(|| { + // We first need to decode it to get the module id and then validate it + // with the secret stored in the state + let module_id = decode_jwt(jwt.clone()).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + + let jwt_secret = state.jwts.get(&module_id).ok_or_else(|| { error!("Unauthorized request. Was the module started correctly?"); SignerModuleError::Unauthorized })?; - req.extensions_mut().insert(module_id.clone()); + validate_jwt(jwt, jwt_secret).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + + req.extensions_mut().insert(module_id); Ok(next.run(req).await) } diff --git a/docs/docs/developing/commit-module.md b/docs/docs/developing/commit-module.md index 3ad46c03..0d36fc7e 100644 --- a/docs/docs/developing/commit-module.md +++ b/docs/docs/developing/commit-module.md @@ -58,6 +58,12 @@ let pubkeys = config.signer_client.get_pubkeys().await.unwrap(); Which will call the `get_pubkeys` endpoint of the [SignerAPI](/api), returning all the consensus pubkeys and the corresponding proxy keys, of your module. +Note that the requests are authenticated using a JWT, that must be regularly refreshed as it expires after a certain time. To do so, you can call: +```rust +config.signer_client.refresh_token().await.unwrap(); +``` +You have the `SIGNER_JWT_EXPIRATION` constant available in the `commit-boost` crate, which is the time in seconds after which the JWT will expire. + Then, we can request a signature either with a consensus key or with a proxy key: ### With a consensus key diff --git a/docs/docs/get_started/running/binary.md b/docs/docs/get_started/running/binary.md index b5c962d1..3708ab19 100644 --- a/docs/docs/get_started/running/binary.md +++ b/docs/docs/get_started/running/binary.md @@ -26,7 +26,7 @@ Modules need some environment variables to work correctly. - `CB_MUX_PATH_{ID}`: optional, override where to load mux validator keys for mux with `id=\{ID\}`. ### Signer Module -- `CB_JWTS`: required, comma-separated list of `MODULE_ID=JWT` to process signature requests. +- `CB_SIGNER_JWT_SECRET`: secret to use for JWT authentication with the Signer module. - `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). @@ -45,7 +45,7 @@ Modules need some environment variables to work correctly. #### Commit modules - `CB_SIGNER_URL`: required, url to the signer module server. -- `CB_SIGNER_JWT`: required, jwt to use for signature requests (needs to match what is in `CB_JWTS`). +- `CB_SIGNER_JWT`: required, jwt to use for signature requests. #### Events modules - `CB_BUILDER_PORT`: required, port to open to receive builder events from the PBS module. diff --git a/docs/package.json b/docs/package.json index efc2b9bd..36a22a33 100644 --- a/docs/package.json +++ b/docs/package.json @@ -41,5 +41,6 @@ }, "engines": { "node": ">=18.0" - } -} \ No newline at end of file + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/examples/da_commit/src/main.rs b/examples/da_commit/src/main.rs index b1c65338..71b61c53 100644 --- a/examples/da_commit/src/main.rs +++ b/examples/da_commit/src/main.rs @@ -43,7 +43,7 @@ fn default_ecdsa() -> bool { } impl DaCommitService { - pub async fn run(self) -> Result<()> { + pub async fn run(&mut self) -> Result<()> { // the config has the signer_client already setup, we can use it to interact // with the Signer API let pubkeys = self.config.signer_client.get_pubkeys().await?.keys; @@ -75,7 +75,7 @@ impl DaCommitService { } pub async fn send_request( - &self, + &mut self, data: u64, pubkey: BlsPublicKey, proxy_bls: BlsPublicKey, @@ -84,27 +84,23 @@ impl DaCommitService { let datagram = Datagram { data }; let request = SignConsensusRequest::builder(pubkey).with_msg(&datagram); - let signature = self.config.signer_client.request_consensus_signature(request); + let signature = self.config.signer_client.request_consensus_signature(request).await?; + + info!("Proposer commitment (consensus): {}", signature); let proxy_request_bls = SignProxyRequest::builder(proxy_bls).with_msg(&datagram); let proxy_signature_bls = - self.config.signer_client.request_proxy_signature_bls(proxy_request_bls); - - let proxy_signature_ecdsa = proxy_ecdsa.map(|proxy_ecdsa| { - let proxy_request_ecdsa = SignProxyRequest::builder(proxy_ecdsa).with_msg(&datagram); - self.config.signer_client.request_proxy_signature_ecdsa(proxy_request_ecdsa) - }); + self.config.signer_client.request_proxy_signature_bls(proxy_request_bls).await?; - let (signature, proxy_signature_bls) = { - let res = tokio::join!(signature, proxy_signature_bls); - (res.0?, res.1?) - }; - - info!("Proposer commitment (consensus): {}", signature); info!("Proposer commitment (proxy BLS): {}", proxy_signature_bls); - if let Some(proxy_signature_ecdsa) = proxy_signature_ecdsa { - let proxy_signature_ecdsa = proxy_signature_ecdsa.await?; + if let Some(proxy_ecdsa) = proxy_ecdsa { + let proxy_request_ecdsa = SignProxyRequest::builder(proxy_ecdsa).with_msg(&datagram); + let proxy_signature_ecdsa = self + .config + .signer_client + .request_proxy_signature_ecdsa(proxy_request_ecdsa) + .await?; info!("Proposer commitment (proxy ECDSA): {}", proxy_signature_ecdsa); } @@ -134,7 +130,7 @@ async fn main() -> Result<()> { "Starting module with custom data" ); - let service = DaCommitService { config }; + let mut service = DaCommitService { config }; if let Err(err) = service.run().await { error!(%err, "Service failed");