Skip to content
Draft
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
5 changes: 5 additions & 0 deletions api/signer-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ paths:

The token **must include** the following claims:
- `exp` (integer): Expiration timestamp
- `route` (string): The route being requested (must be `/signer/v1/get_pubkeys` for this endpoint).
- `module` (string): The ID of the module making the request, which must match a module ID in the Commit-Boost configuration file.
tags:
- Signer
Expand Down Expand Up @@ -73,6 +74,7 @@ paths:
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.
- `route` (string): The route being requested (must be `/signer/v1/request_signature/bls` for this endpoint).
- `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
Expand Down Expand Up @@ -220,6 +222,7 @@ paths:
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.
- `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-bls` for this endpoint).
- `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
Expand Down Expand Up @@ -367,6 +370,7 @@ paths:
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.
- `route` (string): The route being requested (must be `/signer/v1/request_signature/proxy-ecdsa` for this endpoint).
- `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
Expand Down Expand Up @@ -514,6 +518,7 @@ paths:
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.
- `route` (string): The route being requested (must be `/signer/v1/generate_proxy_key` for this endpoint).
- `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
Expand Down
36 changes: 9 additions & 27 deletions crates/common/src/commit/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ use std::path::PathBuf;

use alloy::primitives::Address;
use eyre::WrapErr;
use reqwest::{
Certificate,
header::{AUTHORIZATION, HeaderMap, HeaderValue},
};
use reqwest::Certificate;
use serde::{Deserialize, Serialize};
use url::Url;

Expand Down Expand Up @@ -60,30 +57,13 @@ impl SignerClient {
Ok(Self { url: signer_server_url, client: builder.build()?, module_id, jwt_secret })
}

fn refresh_jwt(&mut self) -> Result<(), SignerClientError> {
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")?;
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(())
}

fn create_jwt_for_payload<T: Serialize>(
&mut self,
route: &str,
payload: &T,
) -> Result<Jwt, SignerClientError> {
let payload_vec = serde_json::to_vec(payload)?;
create_jwt(&self.module_id, &self.jwt_secret, Some(&payload_vec))
create_jwt(&self.module_id, &self.jwt_secret, route, Some(&payload_vec))
.wrap_err("failed to create JWT for payload")
.map_err(SignerClientError::JWTError)
}
Expand All @@ -92,10 +72,12 @@ impl SignerClient {
/// requested.
// TODO: add more docs on how proxy keys work
pub async fn get_pubkeys(&mut self) -> Result<GetPubkeysResponse, SignerClientError> {
self.refresh_jwt()?;
let jwt = create_jwt(&self.module_id, &self.jwt_secret, GET_PUBKEYS_PATH, None)
.wrap_err("failed to create JWT for payload")
.map_err(SignerClientError::JWTError)?;

let url = self.url.join(GET_PUBKEYS_PATH)?;
let res = self.client.get(url).send().await?;
let res = self.client.get(url).bearer_auth(jwt).send().await?;

if !res.status().is_success() {
return Err(SignerClientError::FailedRequest {
Expand All @@ -117,7 +99,7 @@ impl SignerClient {
Q: Serialize,
T: for<'de> Deserialize<'de>,
{
let jwt = self.create_jwt_for_payload(request)?;
let jwt = self.create_jwt_for_payload(route, request)?;

let url = self.url.join(route)?;
let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?;
Expand Down Expand Up @@ -165,7 +147,7 @@ impl SignerClient {
where
T: ProxyId + for<'de> Deserialize<'de>,
{
let jwt = self.create_jwt_for_payload(request)?;
let jwt = self.create_jwt_for_payload(GENERATE_PROXY_KEY_PATH, request)?;

let url = self.url.join(GENERATE_PROXY_KEY_PATH)?;
let res = self.client.post(url).json(&request).bearer_auth(jwt).send().await?;
Expand Down
6 changes: 3 additions & 3 deletions crates/common/src/signer/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,14 +244,14 @@ impl ProxyStore {
serde_json::from_str(&file_content)?;
let signer =
EcdsaSigner::new_from_bytes(&key_and_delegation.secret)?;
let pubkey = signer.address();
let address = signer.address();
let proxy_signer = EcdsaProxySigner {
signer,
delegation: key_and_delegation.delegation,
};

proxy_signers.ecdsa_signers.insert(pubkey, proxy_signer);
ecdsa_map.entry(module_id.clone()).or_default().push(pubkey);
proxy_signers.ecdsa_signers.insert(address, proxy_signer);
ecdsa_map.entry(module_id.clone()).or_default().push(address);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/common/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ pub struct Jwt(pub String);
pub struct JwtClaims {
pub exp: u64,
pub module: ModuleId,
pub route: String,
pub payload_hash: Option<B256>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct JwtAdminClaims {
pub exp: u64,
pub admin: bool,
pub route: String,
pub payload_hash: Option<B256>,
}

Expand Down
71 changes: 54 additions & 17 deletions crates/common/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,17 @@ pub fn print_logo() {
}

/// Create a JWT for the given module id with expiration
pub fn create_jwt(module_id: &ModuleId, secret: &str, payload: Option<&[u8]>) -> eyre::Result<Jwt> {
pub fn create_jwt(
module_id: &ModuleId,
secret: &str,
route: &str,
payload: Option<&[u8]>,
) -> eyre::Result<Jwt> {
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&JwtClaims {
module: module_id.clone(),
route: route.to_string(),
exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION,
payload_hash: payload.map(keccak256),
},
Expand All @@ -361,11 +367,16 @@ pub fn create_jwt(module_id: &ModuleId, secret: &str, payload: Option<&[u8]>) ->
}

// Creates a JWT for module administration
pub fn create_admin_jwt(admin_secret: String, payload: Option<&[u8]>) -> eyre::Result<Jwt> {
pub fn create_admin_jwt(
admin_secret: String,
route: &str,
payload: Option<&[u8]>,
) -> eyre::Result<Jwt> {
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&JwtAdminClaims {
admin: true,
route: route.to_string(),
exp: jsonwebtoken::get_current_timestamp() + SIGNER_JWT_EXPIRATION,
payload_hash: payload.map(keccak256),
},
Expand Down Expand Up @@ -408,7 +419,12 @@ pub fn decode_admin_jwt(jwt: Jwt) -> eyre::Result<JwtAdminClaims> {
}

/// Validate a JWT with the given secret
pub fn validate_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Result<()> {
pub fn validate_jwt(
jwt: Jwt,
secret: &str,
route: &str,
payload: Option<&[u8]>,
) -> eyre::Result<()> {
let mut validation = jsonwebtoken::Validation::default();
validation.leeway = 10;

Expand All @@ -419,6 +435,11 @@ pub fn validate_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Res
)?
.claims;

// Validate the route
if claims.route != route {
eyre::bail!("Token route does not match");
}

// Validate the payload hash if provided
if let Some(payload_bytes) = payload {
if let Some(expected_hash) = claims.payload_hash {
Expand All @@ -436,7 +457,12 @@ pub fn validate_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Res
}

/// Validate an admin JWT with the given secret
pub fn validate_admin_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyre::Result<()> {
pub fn validate_admin_jwt(
jwt: Jwt,
secret: &str,
route: &str,
payload: Option<&[u8]>,
) -> eyre::Result<()> {
let mut validation = jsonwebtoken::Validation::default();
validation.leeway = 10;

Expand All @@ -451,6 +477,11 @@ pub fn validate_admin_jwt(jwt: Jwt, secret: &str, payload: Option<&[u8]>) -> eyr
eyre::bail!("Token is not admin")
}

// Validate the route
if claims.route != route {
eyre::bail!("Token route does not match");
}

// Validate the payload hash if provided
if let Some(payload_bytes) = payload {
if let Some(expected_hash) = claims.payload_hash {
Expand Down Expand Up @@ -546,24 +577,25 @@ mod test {
#[test]
fn test_jwt_validation_no_payload_hash() {
// Check valid JWT
let jwt = create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", None).unwrap();
let jwt =
create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", "/test/route", 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);
let response = validate_jwt(jwt, "secret", "/test/route", 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);
let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkxNzIsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOm51bGx9._OBsNC67KLkk6f6ZQ2_CDbhYUJ2OtZ9egKAmi1L-ymA".to_string());
let response = validate_jwt(expired_jwt, "secret", "/test/route", 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);
let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkxMzQsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOm51bGx9.58QXayg2XeX5lXhIPw-a8kl04DWBEj5wBsqsedTeClo".to_string());
let response = validate_jwt(invalid_jwt, "secret", "/test/route", None);
assert!(response.is_err());
assert_eq!(response.unwrap_err().to_string(), "InvalidSignature");
}
Expand All @@ -577,25 +609,30 @@ mod test {
let payload_bytes = serde_json::to_vec(&payload).unwrap();

// Check valid JWT
let jwt =
create_jwt(&ModuleId("DA_COMMIT".to_string()), "secret", Some(&payload_bytes)).unwrap();
let jwt = create_jwt(
&ModuleId("DA_COMMIT".to_string()),
"secret",
"/test/route",
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()));
assert_eq!(payload_hash, Some(keccak256(&payload_bytes)));
let response = validate_jwt(jwt, "secret", Some(&payload_bytes));
let response = validate_jwt(jwt, "secret", "/test/route", 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", Some(&payload_bytes));
let expired_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTgzNDQsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOiIweGFmODk2MjY0MzUzNTFmYzIwMDBkYmEwM2JiNTlhYjcyZWE0ODJiOWEwMDBmZWQzNmNkMjBlMDU0YjE2NjZmZjEifQ.PYrSxLXadKBgYZlmLam8RBSL32I1T_zAxlZpG6xnnII".to_string());
let response = validate_jwt(expired_jwt, "secret", "/test/route", 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", Some(&payload_bytes));
let invalid_jwt = Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTgyOTkwMDAsIm1vZHVsZSI6IkRBX0NPTU1JVCIsInJvdXRlIjoiL3Rlc3Qvcm91dGUiLCJwYXlsb2FkX2hhc2giOiIweGFmODk2MjY0MzUzNTFmYzIwMDBkYmEwM2JiNTlhYjcyZWE0ODJiOWEwMDBmZWQzNmNkMjBlMDU0YjE2NjZmZjEifQ.mnC-AexkLlR9l98SJbln3DmV6r9XyHYdbjcUVcWdi_8".to_string());
let response = validate_jwt(invalid_jwt, "secret", "/test/route", Some(&payload_bytes));
assert!(response.is_err());
assert_eq!(response.unwrap_err().to_string(), "InvalidSignature");
}
Expand Down
Loading