diff --git a/api/signer-api.yml b/api/signer-api.yml index a6edd6bb..9e11da34 100644 --- a/api/signer-api.yml +++ b/api/signer-api.yml @@ -10,6 +10,12 @@ paths: /signer/v1/get_pubkeys: get: summary: Get a list of public keys for which signatures may be requested + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. tags: - Signer security: @@ -61,6 +67,13 @@ paths: /signer/v1/request_signature/bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested public key. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -201,6 +214,13 @@ paths: /signer/v1/request_signature/proxy-bls: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the BLS private key for the requested proxy public key. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -341,6 +361,13 @@ paths: /signer/v1/request_signature/proxy-ecdsa: post: summary: Request a signature for a 32-byte blob of data (typically a hash), signed by the ECDSA private key for the requested proxy Ethereum address. + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -481,6 +508,13 @@ paths: /signer/v1/generate_proxy_key: post: summary: Request a proxy key be generated for a specific consensus pubkey + description: > + This endpoint requires a valid JWT Bearer token. + + The token **must include** the following claims: + - `exp` (integer): Expiration timestamp + - `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file. + - `payload_hash` (string): The Keccak-256 hash of the JSON-encoded request body, with optional `0x` prefix. This is required to prevent JWT replay attacks. tags: - Signer security: @@ -580,20 +614,6 @@ paths: type: string example: "Internal error" - /status: - get: - summary: Get the status of the Signer API module - tags: - - Management - responses: - "200": - description: Success - content: - text/plain: - schema: - type: string - example: "OK" - components: securitySchemes: BearerAuth: diff --git a/crates/common/src/commit/client.rs b/crates/common/src/commit/client.rs index 5fda0d23..e7466d6c 100644 --- a/crates/common/src/commit/client.rs +++ b/crates/common/src/commit/client.rs @@ -42,7 +42,7 @@ pub struct SignerClient { impl SignerClient { /// Create a new SignerClient pub fn new(signer_server_url: Url, jwt_secret: Jwt, module_id: ModuleId) -> eyre::Result { - let jwt = create_jwt(&module_id, &jwt_secret)?; + let jwt = create_jwt(&module_id, &jwt_secret, None)?; let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", jwt)).wrap_err("invalid jwt")?; @@ -67,7 +67,7 @@ impl SignerClient { 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 jwt = create_jwt(&self.module_id, &self.jwt_secret, None)?; let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", jwt)).wrap_err("invalid jwt")?; @@ -85,6 +85,16 @@ impl SignerClient { Ok(()) } + fn create_jwt_for_payload( + &mut self, + payload: &T, + ) -> Result { + let payload_vec = serde_json::to_vec(payload)?; + create_jwt(&self.module_id, &self.jwt_secret, Some(&payload_vec)) + .wrap_err("failed to create JWT for payload") + .map_err(SignerClientError::JWTError) + } + /// Request a list of validator pubkeys for which signatures can be /// requested. // TODO: add more docs on how proxy keys work @@ -114,10 +124,10 @@ impl SignerClient { Q: Serialize, T: for<'de> Deserialize<'de>, { - self.refresh_jwt()?; + let jwt = self.create_jwt_for_payload(request)?; let url = self.url.join(route)?; - let res = self.client.post(url).json(&request).send().await?; + let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?; let status = res.status(); let response_bytes = res.bytes().await?; @@ -162,10 +172,10 @@ impl SignerClient { where T: ProxyId + for<'de> Deserialize<'de>, { - self.refresh_jwt()?; + let jwt = self.create_jwt_for_payload(request)?; let url = self.url.join(GENERATE_PROXY_KEY_PATH)?; - let res = self.client.post(url).json(&request).send().await?; + let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?; let status = res.status(); let response_bytes = res.bytes().await?; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 36c27fc7..9b845c61 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -25,13 +25,15 @@ pub struct Jwt(pub String); #[derive(Debug, Serialize, Deserialize)] pub struct JwtClaims { pub exp: u64, - pub module: String, + pub module: ModuleId, + pub payload_hash: Option, } #[derive(Debug, Serialize, Deserialize)] -pub struct JwtAdmin { +pub struct JwtAdminClaims { pub exp: u64, pub admin: bool, + pub payload_hash: Option, } #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 9c25656d..35f68959 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -5,7 +5,10 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use alloy::{hex, primitives::U256}; +use alloy::{ + hex, + primitives::{keccak256, U256}, +}; use axum::http::HeaderValue; use futures::StreamExt; use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; @@ -27,7 +30,7 @@ use crate::{ config::LogsSettings, constants::SIGNER_JWT_EXPIRATION, pbs::HEADER_VERSION_VALUE, - types::{BlsPublicKey, Chain, Jwt, JwtAdmin, JwtClaims, ModuleId}, + types::{BlsPublicKey, Chain, Jwt, JwtAdminClaims, JwtClaims, ModuleId}, }; const MILLIS_PER_SECOND: u64 = 1_000; @@ -343,12 +346,13 @@ pub fn print_logo() { } /// Create a JWT for the given module id with expiration -pub fn create_jwt(module_id: &ModuleId, secret: &str) -> eyre::Result { +pub fn create_jwt(module_id: &ModuleId, secret: &str, payload: Option<&[u8]>) -> eyre::Result { jsonwebtoken::encode( &jsonwebtoken::Header::default(), &JwtClaims { - module: module_id.to_string(), + module: module_id.clone(), exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + payload_hash: payload.map(keccak256), }, &jsonwebtoken::EncodingKey::from_secret(secret.as_ref()), ) @@ -356,54 +360,111 @@ pub fn create_jwt(module_id: &ModuleId, secret: &str) -> eyre::Result { .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 { +// Creates a JWT for module administration +pub fn create_admin_jwt(admin_secret: String, payload: Option<&[u8]>) -> eyre::Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &JwtAdminClaims { + admin: true, + exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, + payload_hash: payload.map(keccak256), + }, + &jsonwebtoken::EncodingKey::from_secret(admin_secret.as_ref()), + ) + .map_err(Into::into) + .map(Jwt::from) +} + +/// Decode a JWT and return the JWT claims. IMPORTANT: This function does not +/// validate the JWT, it only obtains the claims. +pub fn decode_jwt(jwt: Jwt) -> eyre::Result { + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let claims = jsonwebtoken::decode::( + jwt.as_str(), + &jsonwebtoken::DecodingKey::from_secret(&[]), + &validation, + )? + .claims; + + Ok(claims) +} + +/// Decode an administrator JWT and return the JWT claims. IMPORTANT: This +/// function does not validate the JWT, it only obtains the claims. +pub fn decode_admin_jwt(jwt: Jwt) -> eyre::Result { let mut validation = jsonwebtoken::Validation::default(); validation.insecure_disable_signature_validation(); - let module = jsonwebtoken::decode::( + let claims = jsonwebtoken::decode::( jwt.as_str(), &jsonwebtoken::DecodingKey::from_secret(&[]), &validation, )? - .claims - .module - .into(); + .claims; - Ok(module) + Ok(claims) } /// Validate a JWT with the given secret -pub fn validate_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { +pub fn validate_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Result<()> { let mut validation = jsonwebtoken::Validation::default(); validation.leeway = 10; - jsonwebtoken::decode::( + let claims = jsonwebtoken::decode::( jwt.as_str(), &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), &validation, - ) - .map(|_| ()) - .map_err(From::from) + )? + .claims; + + // Validate the payload hash if provided + if let Some(payload_bytes) = payload { + if let Some(expected_hash) = claims.payload_hash { + let actual_hash = keccak256(payload_bytes); + if actual_hash != expected_hash { + eyre::bail!("Payload hash does not match"); + } + } else { + eyre::bail!("JWT does not contain a payload hash"); + } + } else if claims.payload_hash.is_some() { + eyre::bail!("JWT contains a payload hash but no payload was provided"); + } + Ok(()) } /// Validate an admin JWT with the given secret -pub fn validate_admin_jwt(jwt: Jwt, secret: &str) -> eyre::Result<()> { +pub fn validate_admin_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Result<()> { let mut validation = jsonwebtoken::Validation::default(); validation.leeway = 10; - let token = jsonwebtoken::decode::( + let claims = jsonwebtoken::decode::( jwt.as_str(), &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()), &validation, - )?; + )? + .claims; - if token.claims.admin { - Ok(()) - } else { + if !claims.admin { eyre::bail!("Token is not admin") } + + // Validate the payload hash if provided + if let Some(payload_bytes) = payload { + if let Some(expected_hash) = claims.payload_hash { + let actual_hash = keccak256(payload_bytes); + if actual_hash != expected_hash { + eyre::bail!("Payload hash does not match"); + } + } else { + eyre::bail!("JWT does not contain a payload hash"); + } + } else if claims.payload_hash.is_some() { + eyre::bail!("JWT contains a payload hash but no payload was provided"); + } + Ok(()) } /// Generates a random string @@ -477,27 +538,64 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { #[cfg(test)] mod test { + use alloy::primitives::keccak256; + use super::{create_jwt, decode_jwt, validate_jwt}; use crate::types::{Jwt, ModuleId}; #[test] - fn test_jwt_validation() { + fn test_jwt_validation_no_payload_hash() { + // Check valid JWT + let jwt = create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", None).unwrap(); + let claims = decode_jwt(jwt.clone()).unwrap(); + let module_id = claims.module; + let payload_hash = claims.payload_hash; + assert_eq!(module_id, ModuleId("DA_COMMIT".to_string())); + assert!(payload_hash.is_none()); + let response = validate_jwt(jwt, "secret", None); + 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", None); + 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", None); + assert!(response.is_err()); + assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); + } + + #[test] + fn test_jwt_validation_with_payload() { + // Pretend payload + let payload = serde_json::json!({ + "data": "test" + }); + let payload_bytes = serde_json::to_vec(&payload).unwrap(); + // Check valid JWT - let jwt = create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret").unwrap(); - let module_id = decode_jwt(jwt.clone()).unwrap(); + let jwt = + create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", Some(&payload_bytes)).unwrap(); + let claims = decode_jwt(jwt.clone()).unwrap(); + let module_id = claims.module; + let payload_hash = claims.payload_hash; assert_eq!(module_id, ModuleId("DA_COMMIT".to_string())); - let response = validate_jwt(jwt, "secret"); + assert_eq!(payload_hash, Some(keccak256(&payload_bytes))); + let response = validate_jwt(jwt, "secret", Some(&payload_bytes)); 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"); + let response = validate_jwt(expired_jwt, "secret", Some(&payload_bytes)); 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"); + let response = validate_jwt(invalid_jwt, "secret", Some(&payload_bytes)); assert!(response.is_err()); assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); } diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index f7ce3f0b..e471a6fb 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -5,8 +5,9 @@ use std::{ time::{Duration, Instant}, }; -use alloy::primitives::{Address, B256, U256}; +use alloy::primitives::{keccak256, Address, B256, U256}; use axum::{ + body::{to_bytes, Body}, extract::{ConnectInfo, Request, State}, http::StatusCode, middleware::{self, Next}, @@ -47,6 +48,8 @@ use crate::{ metrics::{uri_to_tag, SIGNER_METRICS_REGISTRY, SIGNER_STATUS}, }; +pub const REQUEST_MAX_BODY_LENGTH: usize = 1024 * 1024; // 1 MB + /// Implements the Signer API and provides a service for signing requests pub struct SigningService; @@ -159,16 +162,24 @@ async fn jwt_auth( State(state): State, TypedHeader(auth): TypedHeader>, addr: ConnectInfo, - mut req: Request, + req: Request, next: Next, ) -> Result { // Check if the request needs to be rate limited let client_ip = addr.ip(); check_jwt_rate_limit(&state, &client_ip)?; + // Clone the request so we can read the body + let (parts, body) = req.into_parts(); + let bytes = to_bytes(body, REQUEST_MAX_BODY_LENGTH).await.map_err(|e| { + error!("Failed to read request body: {e}"); + SignerModuleError::RequestError(e.to_string()) + })?; + // Process JWT authorization - match check_jwt_auth(&auth, &state) { + match check_jwt_auth(&auth, &state, &bytes) { Ok(module_id) => { + let mut req = Request::from_parts(parts, Body::from(bytes)); req.extensions_mut().insert(module_id); Ok(next.run(req).await) } @@ -224,42 +235,83 @@ fn check_jwt_rate_limit(state: &SigningState, client_ip: &IpAddr) -> Result<(), fn check_jwt_auth( auth: &Authorization, state: &SigningState, + body: &[u8], ) -> Result { let jwt: Jwt = auth.token().to_string().into(); // 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| { + let claims = decode_jwt(jwt.clone()).map_err(|e| { error!("Unauthorized request. Invalid JWT: {e}"); SignerModuleError::Unauthorized })?; let guard = state.jwts.read(); - let jwt_config = guard.get(&module_id).ok_or_else(|| { + let jwt_config = guard.get(&claims.module).ok_or_else(|| { error!("Unauthorized request. Was the module started correctly?"); SignerModuleError::Unauthorized })?; - validate_jwt(jwt, &jwt_config.jwt_secret).map_err(|e| { - error!("Unauthorized request. Invalid JWT: {e}"); - SignerModuleError::Unauthorized - })?; - Ok(module_id) + if body.is_empty() { + // Skip payload hash comparison for requests without a body + validate_jwt(jwt, &jwt_config.jwt_secret, None).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + } else { + validate_jwt(jwt, &jwt_config.jwt_secret, Some(body)).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + + // Make sure the request contains a hash of the payload in its claims + if !body.is_empty() { + let payload_hash = keccak256(body); + if claims.payload_hash.is_none() || claims.payload_hash != Some(payload_hash) { + error!("Unauthorized request. Invalid payload hash in JWT claims"); + return Err(SignerModuleError::Unauthorized); + } + } + } + + Ok(claims.module) } async fn admin_auth( State(state): State, TypedHeader(auth): TypedHeader>, + addr: ConnectInfo, req: Request, next: Next, ) -> Result { - let jwt: Jwt = auth.token().to_string().into(); + // Check if the request needs to be rate limited + let client_ip = addr.ip(); + check_jwt_rate_limit(&state, &client_ip)?; - validate_admin_jwt(jwt, &state.admin_secret.read()).map_err(|e| { - error!("Unauthorized request. Invalid JWT: {e}"); - SignerModuleError::Unauthorized + // Clone the request so we can read the body + let (parts, body) = req.into_parts(); + let bytes = to_bytes(body, REQUEST_MAX_BODY_LENGTH).await.map_err(|e| { + error!("Failed to read request body: {e}"); + SignerModuleError::RequestError(e.to_string()) })?; + let jwt: Jwt = auth.token().to_string().into(); + + // Validate the admin JWT + if bytes.is_empty() { + // Skip payload hash comparison for requests without a body + validate_admin_jwt(jwt, &state.admin_secret.read(), None).map_err(|e| { + error!("Unauthorized request. Invalid JWT: {e}"); + SignerModuleError::Unauthorized + })?; + } else { + validate_admin_jwt(jwt, &state.admin_secret.read(), Some(&bytes)).map_err(|e| { + error!("Unauthorized request. Invalid payload hash in JWT claims: {e}"); + SignerModuleError::Unauthorized + })?; + } + + let req = Request::from_parts(parts, Body::from(bytes)); Ok(next.run(req).await) } diff --git a/tests/src/signer_service.rs b/tests/src/signer_service.rs index c2528cd9..e4cdd4e6 100644 --- a/tests/src/signer_service.rs +++ b/tests/src/signer_service.rs @@ -3,9 +3,8 @@ use std::{collections::HashMap, time::Duration}; use cb_common::{ commit::request::GetPubkeysResponse, config::{ModuleSigningConfig, StartSignerConfig}, - constants::SIGNER_JWT_EXPIRATION, signer::{SignerLoader, ValidatorKeysFormat}, - types::{Chain, Jwt, JwtAdmin, ModuleId}, + types::{Chain, ModuleId}, utils::bls_pubkey_from_hex, }; use cb_signer::service::SigningService; @@ -71,17 +70,3 @@ pub async fn verify_pubkeys(response: Response) -> Result<()> { } Ok(()) } - -// Creates a JWT for module administration -pub fn create_admin_jwt(admin_secret: String) -> Result { - jsonwebtoken::encode( - &jsonwebtoken::Header::default(), - &JwtAdmin { - admin: true, - exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION, - }, - &jsonwebtoken::EncodingKey::from_secret(admin_secret.as_ref()), - ) - .map_err(Into::into) - .map(Jwt::from) -} diff --git a/tests/tests/signer_jwt_auth.rs b/tests/tests/signer_jwt_auth.rs index 63f0783f..a4510af0 100644 --- a/tests/tests/signer_jwt_auth.rs +++ b/tests/tests/signer_jwt_auth.rs @@ -2,13 +2,16 @@ use std::{collections::HashMap, time::Duration}; use alloy::primitives::b256; use cb_common::{ - commit::constants::{GET_PUBKEYS_PATH, REVOKE_MODULE_PATH}, + commit::{ + constants::{GET_PUBKEYS_PATH, REVOKE_MODULE_PATH}, + request::RevokeModuleRequest, + }, config::{load_module_signing_configs, ModuleSigningConfig}, types::ModuleId, - utils::create_jwt, + utils::{create_admin_jwt, create_jwt}, }; use cb_tests::{ - signer_service::{create_admin_jwt, start_server, verify_pubkeys}, + signer_service::{start_server, verify_pubkeys}, utils::{self, setup_test_env}, }; use eyre::Result; @@ -42,7 +45,7 @@ async fn test_signer_jwt_auth_success() -> Result<()> { 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 jwt = create_jwt(&module_id, &jwt_config.jwt_secret, None)?; 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?; @@ -61,7 +64,7 @@ async fn test_signer_jwt_auth_fail() -> Result<()> { let start_config = start_server(20101, &mod_cfgs, ADMIN_SECRET.to_string()).await?; // Run a pubkeys request - this should fail due to invalid JWT - let jwt = create_jwt(&module_id, "incorrect secret")?; + let jwt = create_jwt(&module_id, "incorrect secret", None)?; 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?; @@ -83,7 +86,7 @@ async fn test_signer_jwt_rate_limit() -> Result<()> { 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 jwt = create_jwt(&module_id, "incorrect secret", None)?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, GET_PUBKEYS_PATH); for _ in 0..start_config.jwt_auth_fail_limit { @@ -92,7 +95,7 @@ async fn test_signer_jwt_rate_limit() -> Result<()> { } // Run another request - this should fail due to rate limiting now - let jwt = create_jwt(&module_id, &mod_cfg.jwt_secret)?; + let jwt = create_jwt(&module_id, &mod_cfg.jwt_secret, None)?; let response = client.get(&url).bearer_auth(&jwt).send().await?; assert!(response.status() == StatusCode::TOO_MANY_REQUESTS); @@ -116,8 +119,7 @@ async fn test_signer_revoked_jwt_fail() -> Result<()> { let start_config = start_server(20400, &mod_cfgs, admin_secret.clone()).await?; // Run as many pubkeys requests as the fail limit - let jwt = create_jwt(&module_id, JWT_SECRET)?; - let admin_jwt = create_admin_jwt(admin_secret)?; + let jwt = create_jwt(&module_id, JWT_SECRET, None)?; let client = reqwest::Client::new(); // At first, test module should be allowed to request pubkeys @@ -125,14 +127,13 @@ async fn test_signer_revoked_jwt_fail() -> Result<()> { let response = client.get(&url).bearer_auth(&jwt).send().await?; assert!(response.status() == StatusCode::OK); + let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; + let body_bytes = serde_json::to_vec(&revoke_body)?; + let admin_jwt = create_admin_jwt(admin_secret, Some(&body_bytes))?; + let revoke_url = format!("http://{}{}", start_config.endpoint, REVOKE_MODULE_PATH); - let response = client - .post(&revoke_url) - .header("content-type", "application/json") - .body(reqwest::Body::wrap(format!("{{\"module_id\": \"{JWT_MODULE}\"}}"))) - .bearer_auth(&admin_jwt) - .send() - .await?; + let response = + client.post(&revoke_url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; assert!(response.status() == StatusCode::OK); // After revoke, test module shouldn't be allowed anymore @@ -150,30 +151,21 @@ async fn test_signer_only_admin_can_revoke() -> Result<()> { let mod_cfgs = create_mod_signing_configs().await; let start_config = start_server(20500, &mod_cfgs, admin_secret.clone()).await?; + let revoke_body = RevokeModuleRequest { module_id: ModuleId(JWT_MODULE.to_string()) }; + let body_bytes = serde_json::to_vec(&revoke_body)?; + // Run as many pubkeys requests as the fail limit - let jwt = create_jwt(&module_id, JWT_SECRET)?; - let admin_jwt = create_admin_jwt(admin_secret)?; + let jwt = create_jwt(&module_id, JWT_SECRET, Some(&body_bytes))?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, REVOKE_MODULE_PATH); // Module JWT shouldn't be able to revoke modules - let response = client - .post(&url) - .header("content-type", "application/json") - .body(reqwest::Body::wrap(format!("{{\"module_id\": \"{JWT_MODULE}\"}}"))) - .bearer_auth(&jwt) - .send() - .await?; + let response = client.post(&url).json(&revoke_body).bearer_auth(&jwt).send().await?; assert!(response.status() == StatusCode::UNAUTHORIZED); // Admin should be able to revoke modules - let response = client - .post(&url) - .header("content-type", "application/json") - .body(reqwest::Body::wrap(format!("{{\"module_id\": \"{JWT_MODULE}\"}}"))) - .bearer_auth(&admin_jwt) - .send() - .await?; + let admin_jwt = create_admin_jwt(admin_secret, Some(&body_bytes))?; + let response = client.post(&url).json(&revoke_body).bearer_auth(&admin_jwt).send().await?; assert!(response.status() == StatusCode::OK); Ok(()) diff --git a/tests/tests/signer_request_sig.rs b/tests/tests/signer_request_sig.rs index 8a67d95b..fce8eaf7 100644 --- a/tests/tests/signer_request_sig.rs +++ b/tests/tests/signer_request_sig.rs @@ -60,7 +60,8 @@ async fn test_signer_sign_request_good() -> Result<()> { let nonce: u64 = 101; let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); let request = SignConsensusRequest { pubkey: pubkey.clone(), object_root, nonce }; - let jwt = create_jwt(&module_id, &jwt_config.jwt_secret)?; + let payload_bytes = serde_json::to_vec(&request)?; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, Some(&payload_bytes))?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; @@ -97,7 +98,8 @@ async fn test_signer_sign_request_different_module() -> Result<()> { let nonce: u64 = 101; let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); let request = SignConsensusRequest { pubkey: pubkey.clone(), object_root, nonce }; - let jwt = create_jwt(&module_id, &jwt_config.jwt_secret)?; + let payload_bytes = serde_json::to_vec(&request)?; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, Some(&payload_bytes))?; let client = reqwest::Client::new(); let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; @@ -121,3 +123,59 @@ async fn test_signer_sign_request_different_module() -> Result<()> { Ok(()) } + +/// Makes sure the signer service does not allow requests for JWTs that do +/// not match the JWT hash +#[tokio::test] +async fn test_signer_sign_request_incorrect_hash() -> 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(20202, &mod_cfgs, ADMIN_SECRET.to_string()).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let fake_object_root = + b256!("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"); + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let fake_request = + SignConsensusRequest { pubkey: pubkey.clone(), object_root: fake_object_root, nonce }; + let fake_payload_bytes = serde_json::to_vec(&fake_request)?; + let true_object_root = + b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let true_request = SignConsensusRequest { pubkey, object_root: true_object_root, nonce }; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, Some(&fake_payload_bytes))?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&true_request).bearer_auth(&jwt).send().await?; + + // Verify that authorization failed + assert!(response.status() == StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// Makes sure the signer service does not allow signer requests for JWTs that +/// do not include a payload hash +#[tokio::test] +async fn test_signer_sign_request_missing_hash() -> 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(20203, &mod_cfgs, ADMIN_SECRET.to_string()).await?; + let jwt_config = mod_cfgs.get(&module_id).expect("JWT config for 2nd test module not found"); + + // Send a signing request + let nonce: u64 = 101; + let pubkey = BlsPublicKey::deserialize(&PUBKEY_1).unwrap(); + let object_root = b256!("0x0123456789012345678901234567890123456789012345678901234567890123"); + let request = SignConsensusRequest { pubkey, object_root, nonce }; + let jwt = create_jwt(&module_id, &jwt_config.jwt_secret, None)?; + let client = reqwest::Client::new(); + let url = format!("http://{}{}", start_config.endpoint, REQUEST_SIGNATURE_BLS_PATH); + let response = client.post(&url).json(&request).bearer_auth(&jwt).send().await?; + + // Verify that authorization failed + assert!(response.status() == StatusCode::UNAUTHORIZED); + Ok(()) +}