From 488ad24936cf94655932c4f6f316bd11564adb38 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 27 Sep 2023 09:59:54 +1000 Subject: [PATCH] Client JWT verification with multiple secrets (#25) * Client JWT verification with multiple secrets * Fix clippy --- .gitignore | 1 + Cargo.lock | 161 ++++++++++++++++++++++++++++++++++--------- Cargo.toml | 7 +- example-secrets.toml | 2 + src/config.rs | 22 ++++++ src/jwt.rs | 87 +++++++++++++++++++++++ src/main.rs | 43 ++++++++++-- 7 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 example-secrets.toml create mode 100644 src/jwt.rs diff --git a/.gitignore b/.gitignore index e9868bd..ba5a49f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target *.swp +*.toml diff --git a/Cargo.lock b/Cargo.lock index efe04a7..40d2ca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,7 @@ dependencies = [ "bitflags", "bytes", "futures-util", + "headers", "http", "http-body", "hyper", @@ -682,7 +683,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -1452,7 +1453,7 @@ dependencies = [ "hex", "reqwest", "serde_json", - "sha2 0.10.6", + "sha2 0.10.7", "tree_hash", "types", ] @@ -1570,9 +1571,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common", @@ -1735,15 +1736,20 @@ dependencies = [ "execution_layer", "exit-future", "futures", + "hex", + "hmac 0.12.1", + "jwt", "keccak-hash", "lru 0.10.0", "serde", "serde_json", "serde_repr", + "sha2 0.10.7", "slog", "strum", "task_executor", "tokio", + "toml 0.8.0", "tracing", "tracing-slog", "tracing-subscriber", @@ -1760,7 +1766,7 @@ dependencies = [ "base16ct", "crypto-bigint", "der", - "digest 0.10.6", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1856,6 +1862,12 @@ dependencies = [ "types", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.1" @@ -1948,7 +1960,7 @@ dependencies = [ "bls", "num-bigint-dig", "ring", - "sha2 0.10.6", + "sha2 0.10.7", "zeroize", ] @@ -2124,7 +2136,7 @@ dependencies = [ "cpufeatures", "lazy_static", "ring", - "sha2 0.10.6", + "sha2 0.10.7", ] [[package]] @@ -2673,7 +2685,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 1.9.3", "slab", "tokio", "tokio-util 0.7.8", @@ -2722,6 +2734,12 @@ dependencies = [ "ahash 0.8.3", ] +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + [[package]] name = "hashlink" version = "0.7.0" @@ -2842,7 +2860,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -3119,6 +3137,16 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", +] + [[package]] name = "inout" version = "0.1.3" @@ -3252,6 +3280,21 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest 0.10.7", + "hmac 0.12.1", + "serde", + "serde_json", + "sha2 0.10.7", +] + [[package]] name = "k256" version = "0.11.6" @@ -3261,7 +3304,7 @@ dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", - "sha2 0.10.6", + "sha2 0.10.7", "sha3 0.10.7", ] @@ -3413,7 +3456,7 @@ dependencies = [ "prost-build", "rand 0.8.5", "rw-stream-sink", - "sha2 0.10.6", + "sha2 0.10.7", "smallvec", "thiserror", "unsigned-varint 0.7.1", @@ -3449,7 +3492,7 @@ dependencies = [ "rand 0.8.5", "rw-stream-sink", "sec1", - "sha2 0.10.6", + "sha2 0.10.7", "smallvec", "thiserror", "unsigned-varint 0.7.1", @@ -3522,7 +3565,7 @@ dependencies = [ "prost-codec", "rand 0.8.5", "regex", - "sha2 0.10.6", + "sha2 0.10.7", "smallvec", "thiserror", "unsigned-varint 0.7.1", @@ -3563,7 +3606,7 @@ dependencies = [ "multihash 0.17.0", "quick-protobuf", "rand 0.8.5", - "sha2 0.10.6", + "sha2 0.10.7", "thiserror", "zeroize", ] @@ -3634,7 +3677,7 @@ dependencies = [ "prost", "prost-build", "rand 0.8.5", - "sha2 0.10.6", + "sha2 0.10.7", "snow", "static_assertions", "thiserror", @@ -3918,7 +3961,7 @@ dependencies = [ "regex", "serde", "serde_derive", - "sha2 0.10.6", + "sha2 0.10.7", "slog", "smallvec", "snap", @@ -4112,7 +4155,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -4339,9 +4382,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c346cf9999c631f002d8f977c4eaeaa0e6386f16007202308d0b3757522c2cc" dependencies = [ "core2", - "digest 0.10.6", + "digest 0.10.7", "multihash-derive", - "sha2 0.10.6", + "sha2 0.10.7", "unsigned-varint 0.7.1", ] @@ -4768,7 +4811,7 @@ checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" dependencies = [ "ecdsa", "elliptic-curve", - "sha2 0.10.6", + "sha2 0.10.7", ] [[package]] @@ -4779,7 +4822,7 @@ checksum = "dfc8c5bf642dde52bb9e87c0ecd8ca5a76faac2eeed98dedb7c717997e1080aa" dependencies = [ "ecdsa", "elliptic-curve", - "sha2 0.10.6", + "sha2 0.10.7", ] [[package]] @@ -4953,7 +4996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 1.9.3", ] [[package]] @@ -5123,7 +5166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17d47ce914bf4de440332250b0edd23ce48c005f59fab39d3335866b114f11a" dependencies = [ "thiserror", - "toml", + "toml 0.5.11", ] [[package]] @@ -6126,6 +6169,15 @@ dependencies = [ "syn 2.0.15", ] +[[package]] +name = "serde_spanned" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6166,7 +6218,7 @@ version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ - "indexmap", + "indexmap 1.9.3", "ryu", "serde", "yaml-rust", @@ -6193,7 +6245,7 @@ checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -6211,13 +6263,13 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.6", + "digest 0.10.7", ] [[package]] @@ -6238,7 +6290,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54c2bb1a323307527314a36bfb73f24febb08ce2b8a554bf4ffd6f51ad15198c" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", "keccak", ] @@ -6266,7 +6318,7 @@ version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" dependencies = [ - "digest 0.10.6", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -6443,7 +6495,7 @@ dependencies = [ "rand_core 0.6.4", "ring", "rustc_version 0.4.0", - "sha2 0.10.6", + "sha2 0.10.7", "subtle", ] @@ -7109,6 +7161,40 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c226a7bba6d859b63c92c4b4fe69c5b6b72d0cb897dbc8e6012298e6154cb56e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ff63e60a958cefbb518ae1fd6566af80d9d4be430a33f3723dfc47d1d411d95" +dependencies = [ + "indexmap 2.0.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -7862,7 +7948,7 @@ dependencies = [ "sdp", "serde", "serde_json", - "sha2 0.10.6", + "sha2 0.10.7", "stun", "thiserror", "time 0.3.20", @@ -7925,7 +8011,7 @@ dependencies = [ "sec1", "serde", "sha1", - "sha2 0.10.6", + "sha2 0.10.7", "signature", "subtle", "thiserror", @@ -8313,6 +8399,15 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "winnow" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 9257083..670e607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ eth2_network_config = { git = "https://github.com/michaelsproul/lighthouse", rev eth2 = { git = "https://github.com/michaelsproul/lighthouse", rev = "c144ae391edc938dbc5e185a99df78b8c8cb1c76" } ethereum_serde_utils = "0.5.1" tokio = { version = "1.0.0", features = ["rt-multi-thread"] } -axum = "0.6.10" +axum = { version = "0.6.10", features = ["headers"] } lru = "0.10.0" serde_json = "1.0.0" serde = { version = "1.0.0", features = ["derive"] } @@ -27,6 +27,11 @@ slog = "2.7.0" clap = { version = "4.0.0", features = ["derive"] } strum = { version = "0.24.1", features = ["derive", "strum_macros"] } keccak-hash = "0.10.0" +jwt = "0.16.0" +hmac = "0.12.1" +sha2 = "0.10.7" +toml = "0.8.0" +hex = "0.4.3" [patch] [patch.crates-io] diff --git a/example-secrets.toml b/example-secrets.toml new file mode 100644 index 0000000..037d320 --- /dev/null +++ b/example-secrets.toml @@ -0,0 +1,2 @@ +[secrets] +node1 = "5ffdbc3273639d82b7c22c3cffd41c8f4f5eee1824745b211309b6a43c06f93d" diff --git a/src/config.rs b/src/config.rs index fbebd07..7e54a1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,9 @@ use clap::{builder::PossibleValue, Parser, ValueEnum}; use eth2_network_config::Eth2NetworkConfig; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::net::IpAddr; +use std::path::{Path, PathBuf}; use std::str::FromStr; use strum::{EnumString, IntoStaticStr}; @@ -19,6 +22,11 @@ pub struct Config { /// Path to the JWT secret for the primary execution engine. #[arg(long, value_name = "PATH")] pub ee_jwt_secret: String, + /// Path to TOML file JWT secrets for the non-canonical CL clients. + /// + /// See docs for TOML file format. + #[arg(long, value_name = "PATH")] + pub client_jwt_secrets: PathBuf, /// Number of recent newPayload messages to cache in memory. #[arg(long, value_name = "N", default_value = "64")] pub new_payload_cache_size: usize, @@ -70,6 +78,20 @@ pub struct Config { pub body_limit_mb: usize, } +#[derive(Deserialize, Serialize)] +pub struct ClientJwtSecrets { + pub secrets: HashMap, +} + +impl ClientJwtSecrets { + pub fn from_file(path: &Path) -> Result { + let secrets_str = std::fs::read_to_string(path) + .map_err(|e| format!("IO error reading secrets from {}: {}", path.display(), e))?; + toml::from_str(&secrets_str) + .map_err(|e| format!("Parse error reading secrets from {}: {}", path.display(), e)) + } +} + #[derive(Debug, Clone)] pub struct Network { pub network: Eth2NetworkConfig, diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 0000000..60d62e3 --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,87 @@ +//! JWT authentication supporting multiple secrets identified by ID. +use crate::config::ClientJwtSecrets; +use hmac::{Hmac, Mac}; +use jwt::{Error, Header, Token, Unverified, Verified, VerifyWithKey}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::HashMap; +use std::path::Path; + +pub type VerifiedToken = Token; +pub type UnverifiedToken<'a> = Token>; +pub type Secret = Hmac; + +/// Collection of JWT secrets organised by ID, allowing for each client to use its own secret. +pub struct KeyCollection { + secrets: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct Claims { + /// issued-at claim. Represented as seconds passed since UNIX_EPOCH. + iat: u64, + /// Optional unique identifier for the CL node. + id: Option, + /// Optional client version for the CL node. + clv: Option, +} + +pub fn verify_single_token(token: &str, secret: &Secret) -> Result { + token.verify_with_key(secret).map_err(convert_err) +} + +fn verify_parsed_token(token: UnverifiedToken, secret: &Secret) -> Result { + token.verify_with_key(secret).map_err(convert_err) +} + +impl KeyCollection { + pub fn verify(&self, token: &str) -> Result { + let parsed_token = UnverifiedToken::parse_unverified(token).map_err(convert_err)?; + + // Look up the key by ID. Unlike other JWT implementations, the engine API puts the key ID + // inside the claim. + let secret = parsed_token + .claims() + .id + .as_ref() + .and_then(|id| Some((id, self.secrets.get(id)?))); + + if let Some((id, secret)) = secret { + tracing::trace!(id = id, "matched JWT secret by ID"); + return verify_parsed_token(parsed_token, secret); + } + + // Otherwise try every token available (slow). + // TODO: put this behind a CLI flag once more CL clients support key IDs + for (id, secret) in &self.secrets { + if let Ok(token) = verify_single_token(token, secret) { + tracing::trace!(id = id, "matched JWT secret by iteration"); + return Ok(token); + } + } + + // No matching key found. + Err("No matching JWT secret found".into()) + } + + pub fn load(path: &Path) -> Result { + let raw = ClientJwtSecrets::from_file(path)?; + + let mut secrets = HashMap::with_capacity(raw.secrets.len()); + + for (id, hex_secret) in raw.secrets { + let byte_secret = + hex::decode(&hex_secret).map_err(|e| format!("Invalid JWT secret: {e:?}"))?; + + let secret = Secret::new_from_slice(&byte_secret) + .map_err(|e| format!("Invalid JWT secret: {e}"))?; + secrets.insert(id, secret); + } + + Ok(Self { secrets }) + } +} + +fn convert_err(e: Error) -> String { + format!("JWT verification error: {e}") +} diff --git a/src/main.rs b/src/main.rs index 4768f54..fa6add1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use crate::{ config::Config, + jwt::KeyCollection, multiplexer::Multiplexer, transition_config::handle_transition_config, types::{ @@ -8,10 +9,11 @@ use crate::{ }; use axum::{ extract::{rejection::JsonRejection, DefaultBodyLimit, State}, + headers::{authorization::Bearer, Authorization}, http::StatusCode, response::IntoResponse, routing::{get, post}, - Json, Router, + Json, Router, TypedHeader, }; use clap::Parser; use eth2::types::MainnetEthSpec; @@ -31,6 +33,7 @@ use tokio::runtime::Handle; mod base_fee; mod config; mod fcu; +mod jwt; mod logging; mod meta; mod multiplexer; @@ -56,13 +59,18 @@ async fn main() { let body_limit_mb = config.body_limit_mb; let listen_address = config.listen_address; let listen_port = config.listen_port; - let multiplexer = Arc::new(Multiplexer::::new(config, executor, log).unwrap()); + let client_jwt_collection = KeyCollection::load(&config.client_jwt_secrets).unwrap(); + let multiplexer = Multiplexer::::new(config, executor, log).unwrap(); + let app_state = Arc::new(AppState { + client_jwt_collection, + multiplexer, + }); let app = Router::new() .route("/", post(handle_client_json_rpc)) .route("/canonical", post(handle_controller_json_rpc)) .route("/health", get(handle_health)) - .with_state(multiplexer) + .with_state(app_state) .layer(DefaultBodyLimit::max(body_limit_mb * MEGABYTE)); let addr = SocketAddr::from((listen_address, listen_port)); @@ -73,6 +81,11 @@ async fn main() { .unwrap(); } +struct AppState { + client_jwt_collection: KeyCollection, + multiplexer: Multiplexer, +} + // TODO: do something with signal/signal_rx async fn new_task_executor(log: Logger) -> TaskExecutor { let handle = Handle::current(); @@ -82,9 +95,24 @@ async fn new_task_executor(log: Logger) -> TaskExecutor { } async fn handle_client_json_rpc( - State(multiplexer): State>>, + State(state): State>, + TypedHeader(jwt_token_str): TypedHeader>, maybe_requests: Result, JsonRejection>, ) -> Json { + let jwt_key_collection = &state.client_jwt_collection; + let multiplexer = &state.multiplexer; + + // Check JWT auth. + if let Err(e) = jwt_key_collection.verify(jwt_token_str.token()) { + tracing::warn!( + error = ?e, + "JWT auth failed" + ); + return Json(Responses::Single(MaybeErrorResponse::Err( + ErrorResponse::parse_error_generic(serde_json::json!(0), e), + ))); + } + let requests = match maybe_requests { Ok(Json(requests)) => requests, Err(e) => { @@ -96,13 +124,13 @@ async fn handle_client_json_rpc( match requests { Requests::Single(request) => Json(Responses::Single( - process_client_request(&multiplexer, request).await.into(), + process_client_request(multiplexer, request).await.into(), )), Requests::Multiple(requests) => { let mut results = vec![]; for request in requests { - results.push(process_client_request(&multiplexer, request).await.into()); + results.push(process_client_request(multiplexer, request).await.into()); } Json(Responses::Multiple(results)) @@ -140,9 +168,10 @@ async fn process_client_request( } async fn handle_controller_json_rpc( - State(multiplexer): State>>, + State(state): State>, maybe_request: Result, JsonRejection>, ) -> Result, Json> { + let multiplexer = &state.multiplexer; let Json(request) = maybe_request .map_err(|e| ErrorResponse::parse_error_generic(serde_json::json!(0), e.body_text()))?;