Skip to content

CBST2-04: Update JWT secrets on reload and revoke module endpoint #295

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions crates/cli/src/docker_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
]);

Expand All @@ -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()];
Expand Down
1 change: 1 addition & 0 deletions crates/common/src/commit/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
38 changes: 34 additions & 4 deletions crates/common/src/commit/request.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
collections::HashMap,
fmt::{self, Debug, Display},
str::FromStr,
};
Expand All @@ -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 {}
Expand Down Expand Up @@ -198,6 +203,31 @@ pub struct GetPubkeysResponse {
pub keys: Vec<ConsensusProxyMap>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReloadRequest {
#[serde(default, deserialize_with = "deserialize_jwt_secrets")]
pub jwt_secrets: Option<HashMap<ModuleId, String>>,
pub admin_secret: Option<String>,
}

pub fn deserialize_jwt_secrets<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<ModuleId, String>>, 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 {
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/common/src/config/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
5 changes: 4 additions & 1 deletion crates/common/src/config/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,15 @@ pub struct StartSignerConfig {
pub store: Option<ProxyStore>,
pub server_port: u16,
pub jwts: HashMap<ModuleId, String>,
pub admin_secret: String,
pub dirk: Option<DirkConfig>,
}

impl StartSignerConfig {
pub fn load_from_env() -> Result<Self> {
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;
Expand All @@ -107,6 +108,7 @@ impl StartSignerConfig {
loader: Some(loader),
server_port,
jwts,
admin_secret,
store,
dirk: None,
}),
Expand Down Expand Up @@ -135,6 +137,7 @@ impl StartSignerConfig {
chain: config.chain,
server_port,
jwts,
admin_secret,
loader: None,
store,
dirk: Some(DirkConfig {
Expand Down
9 changes: 5 additions & 4 deletions crates/common/src/config/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
Expand All @@ -25,12 +25,13 @@ pub fn load_file_from_env<T: DeserializeOwned>(env: &str) -> Result<T> {
}

/// Loads a map of module id -> jwt secret from a json env
pub fn load_jwt_secrets() -> Result<HashMap<ModuleId, String>> {
pub fn load_jwt_secrets() -> Result<(String, HashMap<ModuleId, String>)> {
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<HashMap<ModuleId, String>> {
pub fn decode_string_to_map(raw: &str) -> Result<HashMap<ModuleId, String>> {
// trim the string and split for comma
raw.trim()
.split(',')
Expand Down
6 changes: 6 additions & 0 deletions crates/common/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion crates/common/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<JwtAdmin>(
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()
Expand Down
4 changes: 4 additions & 0 deletions crates/signer/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand All @@ -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()
}
Expand Down
65 changes: 55 additions & 10 deletions crates/signer/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,7 +47,9 @@ struct SigningState {
manager: Arc<RwLock<SigningManager>>,
/// Map of modules ids to JWT secrets. This also acts as registry of all
/// modules running
jwts: Arc<HashMap<ModuleId, String>>,
jwts: Arc<RwLock<HashMap<ModuleId, String>>>,
/// Secret for the admin JWT
admin_secret: Arc<RwLock<String>>,
}

impl SigningService {
Expand All @@ -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();
Expand All @@ -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<()> {
Expand All @@ -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
})?;
Expand All @@ -123,6 +132,22 @@ async fn jwt_auth(
Ok(next.run(req).await)
}

async fn admin_auth(
State(state): State<SigningState>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
req: Request,
next: Next,
) -> Result<Response, SignerModuleError> {
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<Response, SignerModuleError> {
let url = &req.uri().clone();
Expand Down Expand Up @@ -257,6 +282,7 @@ async fn handle_generate_proxy(

async fn handle_reload(
State(mut state): State<SigningState>,
Json(request): Json<ReloadRequest>,
) -> Result<impl IntoResponse, SignerModuleError> {
let req_id = Uuid::new_v4();

Expand All @@ -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) => {
Expand All @@ -283,6 +317,17 @@ async fn handle_reload(
Ok(StatusCode::OK)
}

async fn handle_revoke_module(
State(state): State<SigningState>,
Json(request): Json<RevokeModuleRequest>,
) -> Result<impl IntoResponse, SignerModuleError> {
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<SigningManager> {
let proxy_store = if let Some(store) = config.store.clone() {
Some(store.init_from_env()?)
Expand Down
9 changes: 9 additions & 0 deletions docs/docs/get_started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<MODULE_ID>=<JWT_SECRET>` 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.
Expand Down
Loading