Skip to content

Commit

Permalink
feat: jwt authentication for RPC (#302)
Browse files Browse the repository at this point in the history
**Motivation**

Json RPC calls to the engine execution API should be authenticated by
bearing a JWT token as specified
https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md

This is to validate that authrpc calls are only issued by the consensus
layer and prevents attack which would come from accidentally exposing
the execution client to the internet.

**Description**

Introduces authentication.rs module which uses the jsonwebtoken crate to
decode and validate tokens issued by the consensus layer.

The tokens contain a "iat" claim which stands for "issued at timestamp",
according to the spec, this unix timestamp must be at most 60 seconds
from the time of validation.

For this PR to pass the CI tests, this one needs to be merged first on
lambdaclass/hive to enable the testing of the authentication by Hive:
https://github.com/lambdaclass/hive/pull/2/files

To enable authentication on our Kurtosis localnet this needs to be
merged:
https://github.com/lambdaclass/ethereum-package/pull/2/files


Closes #13
  • Loading branch information
vicentevieytes authored Aug 23, 2024
1 parent 4754356 commit a6d2446
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 18 deletions.
3 changes: 3 additions & 0 deletions cmd/ethereum_rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ ethereum_rust-net.workspace = true
ethereum_rust-storage.workspace = true
ethereum_rust-evm.workspace = true

bytes.workspace = true
hex.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap = { version = "4.5.4", features = ["cargo"] }
serde_json.workspace = true
tokio = { version = "1.38.0", features = ["full"] }
anyhow = "1.0.86"
rand = "0.8.5"

[[bin]]
name = "ethereum_rust"
Expand Down
7 changes: 7 additions & 0 deletions cmd/ethereum_rust/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ pub fn cli() -> Command {
.value_name("PORT")
.action(ArgAction::Set),
)
.arg(
Arg::new("authrpc.jwtsecret")
.long("authrpc.jwtsecret")
.default_value("jwt.hex")
.value_name("JWTSECRET_PATH")
.action(ArgAction::Set),
)
.arg(
Arg::new("p2p.addr")
.long("p2p.addr")
Expand Down
13 changes: 12 additions & 1 deletion cmd/ethereum_rust/decode.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
use anyhow::Error;
use bytes::Bytes;
use ethereum_rust_core::rlp::decode::RLPDecode as _;
use ethereum_rust_core::types::{Block, Genesis};
use std::{
fs::File,
io::{BufReader, Read as _},
};

pub fn jwtsecret_file(file: &mut File) -> Bytes {
let mut contents = String::new();
file.read_to_string(&mut contents)
.expect("Failed to read jwt secret file");
if contents[0..2] == *"0x" {
contents = contents[2..contents.len()].to_string();
}
hex::decode(contents)
.expect("Secret should be hex encoded")
.into()
}
pub fn chain_file(file: File) -> Result<Vec<Block>, Error> {
let mut chain_rlp_reader = BufReader::new(file);
let mut buf = vec![];
Expand Down
32 changes: 30 additions & 2 deletions cmd/ethereum_rust/ethereum_rust.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use bytes::Bytes;
use ethereum_rust_chain::add_block;
use ethereum_rust_core::types::{Block, Genesis};
use ethereum_rust_net::bootnode::BootNode;
use ethereum_rust_storage::{EngineType, Store};
use std::{
fs::File,
io,
net::{SocketAddr, ToSocketAddrs},
};
Expand Down Expand Up @@ -34,6 +36,9 @@ async fn main() {
let authrpc_port = matches
.get_one::<String>("authrpc.port")
.expect("authrpc.port is required");
let authrpc_jwtsecret = matches
.get_one::<String>("authrpc.jwtsecret")
.expect("authrpc.jwtsecret is required");

let tcp_addr = matches
.get_one::<String>("p2p.addr")
Expand Down Expand Up @@ -91,13 +96,36 @@ async fn main() {
}
info!("Added {} blocks to blockchain", size);
}

let rpc_api = ethereum_rust_rpc::start_api(http_socket_addr, authrpc_socket_addr, store);
let jwt_secret = read_jwtsecret_file(authrpc_jwtsecret);
let rpc_api =
ethereum_rust_rpc::start_api(http_socket_addr, authrpc_socket_addr, store, jwt_secret);
let networking = ethereum_rust_net::start_network(udp_socket_addr, tcp_socket_addr, bootnodes);

try_join!(tokio::spawn(rpc_api), tokio::spawn(networking)).unwrap();
}

fn read_jwtsecret_file(jwt_secret_path: &str) -> Bytes {
match File::open(jwt_secret_path) {
Ok(mut file) => decode::jwtsecret_file(&mut file),
Err(_) => write_jwtsecret_file(jwt_secret_path),
}
}

fn write_jwtsecret_file(jwt_secret_path: &str) -> Bytes {
info!("JWT secret not found in the provided path, generating JWT secret");
let secret = generate_jwt_secret();
std::fs::write(jwt_secret_path, &secret).expect("Unable to write JWT secret file");
hex::decode(secret).unwrap().into()
}

fn generate_jwt_secret() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut secret = [0u8; 32];
rng.fill(&mut secret);
hex::encode(secret)
}

fn read_chain_file(chain_rlp_path: &str) -> Vec<Block> {
let chain_file = std::fs::File::open(chain_rlp_path).expect("Failed to open chain rlp file");
decode::chain_file(chain_file).expect("Failed to decode chain rlp file")
Expand Down
4 changes: 3 additions & 1 deletion crates/rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ axum = "0.7.5"
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
tokio.workspace = true

bytes.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
ethereum_rust-core.workspace = true
ethereum_rust-storage.workspace = true
ethereum_rust-evm.workspace = true
ethereum_rust-chain.workspace = true
hex.workspace = true
axum-extra = {version = "0.9.3", features = ["typed-header"]}
jsonwebtoken = "9.3.0"

[lib]
path = "./rpc.rs"
44 changes: 44 additions & 0 deletions crates/rpc/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use bytes::Bytes;
use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

pub enum AuthenticationError {
InvalidIssuedAtClaim,
TokenDecodingError,
}

// JWT claims struct
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
iat: usize,
id: Option<String>,
clv: Option<String>,
}

/// Authenticates bearer jwt to check that authrpc calls are sent by the consensus layer
pub fn validate_jwt_authentication(token: &str, secret: Bytes) -> Result<(), AuthenticationError> {
let decoding_key = DecodingKey::from_secret(&secret);
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false;
validation.set_required_spec_claims(&["iat"]);
match decode::<Claims>(token, &decoding_key, &validation) {
Ok(token_data) => {
if invalid_issued_at_claim(token_data) {
Err(AuthenticationError::InvalidIssuedAtClaim)
} else {
Ok(())
}
}
Err(_) => Err(AuthenticationError::TokenDecodingError),
}
}

/// Checks that the "iat" timestamp in the claim is less than 60 seconds from now
fn invalid_issued_at_claim(token_data: TokenData<Claims>) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
(now as isize - token_data.claims.iat as isize).abs() > 60
}
88 changes: 74 additions & 14 deletions crates/rpc/rpc.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
use crate::authentication::{validate_jwt_authentication, AuthenticationError};
use bytes::Bytes;
use std::{future::IntoFuture, net::SocketAddr};

use axum::{routing::post, Json, Router};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use engine::{ExchangeCapabilitiesRequest, NewPayloadV3Request};
use eth::{
account::{self, GetBalanceRequest, GetCodeRequest, GetStorageAtRequest},
Expand All @@ -15,12 +21,12 @@ use eth::{
GetTransactionReceiptRequest,
},
};
use serde_json::Value;
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tracing::info;
use utils::{RpcErr, RpcErrorMetadata, RpcErrorResponse, RpcRequest, RpcSuccessResponse};

mod admin;
mod authentication;
mod engine;
mod eth;
mod types;
Expand All @@ -29,15 +35,30 @@ mod utils;
use axum::extract::State;
use ethereum_rust_storage::Store;

pub async fn start_api(http_addr: SocketAddr, authrpc_addr: SocketAddr, storage: Store) {
#[derive(Debug, Clone)]
pub struct RpcApiContext {
storage: Store,
jwt_secret: Bytes,
}

pub async fn start_api(
http_addr: SocketAddr,
authrpc_addr: SocketAddr,
storage: Store,
jwt_secret: Bytes,
) {
let service_context = RpcApiContext {
storage: storage.clone(),
jwt_secret,
};
let http_router = Router::new()
.route("/", post(handle_http_request))
.with_state(storage.clone());
.with_state(service_context.clone());
let http_listener = TcpListener::bind(http_addr).await.unwrap();

let authrpc_router = Router::new()
.route("/", post(handle_authrpc_request))
.with_state(storage);
.with_state(service_context);
let authrpc_listener = TcpListener::bind(authrpc_addr).await.unwrap();

let authrpc_server = axum::serve(authrpc_listener, authrpc_router)
Expand All @@ -60,19 +81,58 @@ async fn shutdown_signal() {
.expect("failed to install Ctrl+C handler");
}

pub async fn handle_authrpc_request(State(storage): State<Store>, body: String) -> Json<Value> {
pub async fn handle_http_request(
State(service_context): State<RpcApiContext>,
body: String,
) -> Json<Value> {
let storage = service_context.storage;
let req: RpcRequest = serde_json::from_str(&body).unwrap();
let res = match map_requests(&req, storage.clone()) {
res @ Ok(_) => res,
_ => map_internal_requests(&req, storage),
};
let res = map_requests(&req, storage.clone());
rpc_response(req.id, res)
}

pub async fn handle_http_request(State(storage): State<Store>, body: String) -> Json<Value> {
let req: RpcRequest = serde_json::from_str(&body).unwrap();
let res = map_requests(&req, storage);
rpc_response(req.id, res)
pub async fn handle_authrpc_request(
State(service_context): State<RpcApiContext>,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
body: String,
) -> Json<Value> {
if auth_header.is_none() {
return Json(
json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "Authorization header missing"}, "id": null}),
);
}
let TypedHeader(auth_header) = auth_header.unwrap();
let storage = service_context.storage;
let secret = service_context.jwt_secret;
let token = auth_header.token();
//Validate the JWT
match validate_jwt_authentication(token, secret) {
Err(AuthenticationError::InvalidIssuedAtClaim) => Json(json!({
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "Invalid iat claim"
},
"id": null
})),
Err(AuthenticationError::TokenDecodingError) => Json(json!({
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "Invalid or missing token"
},
"id": null
})),
Ok(()) => {
// Proceed with the request
let req: RpcRequest = serde_json::from_str(&body).unwrap();
let res = match map_requests(&req, storage.clone()) {
res @ Ok(_) => res,
_ => map_internal_requests(&req, storage),
};
rpc_response(req.id, res)
}
}
}

/// Handle requests that can come from either clients or other users
Expand Down

0 comments on commit a6d2446

Please sign in to comment.