Skip to content

Commit

Permalink
feat(engine): Add JSON Web Token (JWT) token generation and validatio…
Browse files Browse the repository at this point in the history
…n support (#612)

* add basic layout

* port from Reth

* remove unused error

* add test for exp

* wrap up api

* make with_current_timestamp default

* avoid explicit casting

* use &str instead of String

* fix clippy
  • Loading branch information
zerosnacks authored Apr 23, 2024
1 parent cad7935 commit d68a6b7
Show file tree
Hide file tree
Showing 3 changed files with 362 additions and 3 deletions.
19 changes: 17 additions & 2 deletions crates/rpc-types-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,28 @@ thiserror.workspace = true
# jsonrpsee
jsonrpsee-types = { version = "0.22", optional = true }

# jwt
jsonwebtoken = "9.3.0"
rand.workspace = true

[features]
jsonrpsee-types = ["dep:jsonrpsee-types"]
ssz = ["dep:ethereum_ssz", "dep:ethereum_ssz_derive", "alloy-primitives/ssz", "alloy-rpc-types/ssz", "alloy-eips/ssz"]
ssz = [
"dep:ethereum_ssz",
"dep:ethereum_ssz_derive",
"alloy-primitives/ssz",
"alloy-rpc-types/ssz",
"alloy-eips/ssz",
]
kzg = ["alloy-consensus/kzg"]

[dev-dependencies]
alloy-primitives = { workspace = true, features = ["rand", "rlp", "serde", "arbitrary"] }
alloy-primitives = { workspace = true, features = [
"rand",
"rlp",
"serde",
"arbitrary",
] }

arbitrary = { workspace = true, features = ["derive"] }
rand.workspace = true
Expand Down
342 changes: 342 additions & 0 deletions crates/rpc-types-engine/src/jwt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
//! JWT (JSON Web Token) utilities for the Engine API.

use alloy_primitives::hex;
use jsonwebtoken::{
decode, errors::ErrorKind, get_current_timestamp, Algorithm, DecodingKey, Validation,
};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::{str::FromStr, time::Duration};
use thiserror::Error;

/// Errors returned by the [`JwtSecret`]
#[derive(Error, Debug)]
pub enum JwtError {
/// An error encountered while decoding the hexadecimal string for the JWT secret.
#[error(transparent)]
JwtSecretHexDecodeError(#[from] hex::FromHexError),

/// The JWT key length provided is invalid, expecting a specific length.
#[error("JWT key is expected to have a length of {0} digits. {1} digits key provided")]
InvalidLength(usize, usize),

/// The signature algorithm used in the JWT is not supported. Only HS256 is supported.
#[error("unsupported signature algorithm. Only HS256 is supported")]
UnsupportedSignatureAlgorithm,

/// The provided signature in the JWT is invalid.
#[error("provided signature is invalid")]
InvalidSignature,

/// The "iat" (issued-at) claim in the JWT is not within the allowed ±60 seconds from the
/// current time.
#[error("IAT (issued-at) claim is not within ±60 seconds from the current time")]
InvalidIssuanceTimestamp,

/// An error occurred during JWT decoding.
#[error("JWT decoding error: {0}")]
JwtDecodingError(String),
}

/// Length of the hex-encoded 256 bit secret key.
/// A 256-bit encoded string in Rust has a length of 64 digits because each digit represents 4 bits
/// of data. In hexadecimal representation, each digit can have 16 possible values (0-9 and A-F), so
/// 4 bits can be represented using a single hex digit. Therefore, to represent a 256-bit string,
/// we need 64 hexadecimal digits (256 bits ÷ 4 bits per digit = 64 digits).
const JWT_SECRET_LEN: usize = 64;

/// The JWT `iat` (issued-at) claim cannot exceed +-60 seconds from the current time.
const JWT_MAX_IAT_DIFF: Duration = Duration::from_secs(60);

/// The execution layer client MUST support at least the following alg HMAC + SHA256 (HS256)
const JWT_SIGNATURE_ALGO: Algorithm = Algorithm::HS256;

/// Claims in JWT are used to represent a set of information about an entity.
/// Claims are essentially key-value pairs that are encoded as JSON objects and included in the
/// payload of a JWT. They are used to transmit information such as the identity of the entity, the
/// time the JWT was issued, and the expiration time of the JWT, among others.
///
/// The Engine API spec requires that just the `iat` (issued-at) claim is provided.
/// It ignores claims that are optional or additional for this specification.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
/// The "iat" value MUST be a number containing a NumericDate value.
/// According to the RFC A NumericDate represents the number of seconds since
/// the UNIX_EPOCH.
/// - [`RFC-7519 - Spec`](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6)
/// - [`RFC-7519 - Notations`](https://www.rfc-editor.org/rfc/rfc7519#section-2)
pub iat: u64,
/// The "exp" (expiration time) claim identifies the expiration time on or after which the JWT
/// MUST NOT be accepted for processing.
pub exp: Option<u64>,
}

impl Claims {
/// Creates a new instance of [`Claims`] with the current timestamp as the `iat` claim.
fn with_current_timestamp() -> Self {
Self { iat: get_current_timestamp(), exp: None }
}

/// Checks if the `iat` claim is within the allowed range from the current time.
fn is_within_time_window(&self) -> bool {
let now_secs = get_current_timestamp();
now_secs.abs_diff(self.iat) <= JWT_MAX_IAT_DIFF.as_secs()
}
}

impl Default for Claims {
/// By default, the `iat` claim is set to the current timestamp.
fn default() -> Self {
Self::with_current_timestamp()
}
}

/// Value-object holding a reference to a hex-encoded 256-bit secret key.
/// A JWT secret key is used to secure JWT-based authentication. The secret key is
/// a shared secret between the server and the client and is used to calculate a digital signature
/// for the JWT, which is included in the JWT along with its payload.
///
/// See also: [Secret key - Engine API specs](https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#key-distribution)
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct JwtSecret([u8; 32]);

impl JwtSecret {
/// Creates an instance of [`JwtSecret`].
///
/// Returns an error if one of the following applies:
/// - `hex` is not a valid hexadecimal string
/// - `hex` argument length is less than `JWT_SECRET_LEN`
///
/// This strips the leading `0x`, if any.
pub fn from_hex<S: AsRef<str>>(hex: S) -> Result<Self, JwtError> {
let hex: &str = hex.as_ref().trim().trim_start_matches("0x");
if hex.len() != JWT_SECRET_LEN {
Err(JwtError::InvalidLength(JWT_SECRET_LEN, hex.len()))
} else {
let hex_bytes = hex::decode(hex)?;
// is 32bytes, see length check
let bytes = hex_bytes.try_into().expect("is expected len");
Ok(JwtSecret(bytes))
}
}

/// Validates a JWT token along the following rules:
/// - The JWT signature is valid.
/// - The JWT is signed with the `HMAC + SHA256 (HS256)` algorithm.
/// - The JWT `iat` (issued-at) claim is a timestamp within +-60 seconds from the current time.
/// - The JWT `exp` (expiration time) claim is validated by default if defined.
///
/// See also: [JWT Claims - Engine API specs](https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#jwt-claims)
pub fn validate(&self, jwt: &str) -> Result<(), JwtError> {
// Create a new validation object with the required signature algorithm
// and ensure that the `iat` claim is present. The `exp` claim is validated if defined.
let mut validation = Validation::new(JWT_SIGNATURE_ALGO);
validation.set_required_spec_claims(&["iat"]);
let bytes = &self.0;

match decode::<Claims>(jwt, &DecodingKey::from_secret(bytes), &validation) {
Ok(token) => {
if !token.claims.is_within_time_window() {
Err(JwtError::InvalidIssuanceTimestamp)?
}
}
Err(err) => match *err.kind() {
ErrorKind::InvalidSignature => Err(JwtError::InvalidSignature)?,
ErrorKind::InvalidAlgorithm => Err(JwtError::UnsupportedSignatureAlgorithm)?,
_ => {
let detail = format!("{err}");
Err(JwtError::JwtDecodingError(detail))?
}
},
};

Ok(())
}

/// Generates a random [`JwtSecret`] containing a hex-encoded 256 bit secret key.
pub fn random() -> Self {
let random_bytes: [u8; 32] = rand::thread_rng().gen();
let secret = hex::encode(random_bytes);
JwtSecret::from_hex(secret).unwrap()
}

/// Encode the header and claims given and sign the payload using the algorithm from the header
/// and the key.
pub fn encode(&self, claims: &Claims) -> Result<String, jsonwebtoken::errors::Error> {
let bytes = &self.0;
let key = jsonwebtoken::EncodingKey::from_secret(bytes);
let algo = jsonwebtoken::Header::new(Algorithm::HS256);
jsonwebtoken::encode(&algo, claims, &key)
}
}

impl std::fmt::Debug for JwtSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("JwtSecretHash").field(&"{{}}").finish()
}
}

impl FromStr for JwtSecret {
type Err = JwtError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
JwtSecret::from_hex(s)
}
}

#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn from_hex() {
let key = "f79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";
let secret: Result<JwtSecret, _> = JwtSecret::from_hex(key);
assert!(secret.is_ok());

let secret: Result<JwtSecret, _> = JwtSecret::from_hex(key);
assert!(secret.is_ok());
}

#[test]
fn original_key_integrity_across_transformations() {
let original = "f79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";
let secret = JwtSecret::from_hex(original).unwrap();
let bytes = &secret.0;
let computed = hex::encode(bytes);
assert_eq!(original, computed);
}

#[test]
fn secret_has_64_hex_digits() {
let expected_len = 64;
let secret = JwtSecret::random();
let hex = hex::encode(secret.0);
assert_eq!(hex.len(), expected_len);
}

#[test]
fn creation_ok_hex_string_with_0x() {
let hex: String =
"0x7365637265747365637265747365637265747365637265747365637265747365".into();
let result = JwtSecret::from_hex(hex);
assert!(result.is_ok());
}

#[test]
fn creation_error_wrong_len() {
let hex = "f79ae8046";
let result = JwtSecret::from_hex(hex);
assert!(matches!(result, Err(JwtError::InvalidLength(_, _))));
}

#[test]
fn creation_error_wrong_hex_string() {
let hex: String = "This__________Is__________Not_______An____Hex_____________String".into();
let result = JwtSecret::from_hex(hex);
assert!(matches!(result, Err(JwtError::JwtSecretHexDecodeError(_))));
}

#[test]
fn validation_ok() {
let secret = JwtSecret::random();
let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Ok(())));
}

#[test]
fn validation_with_current_time_ok() {
let secret = JwtSecret::random();
let claims = Claims::default();
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Ok(())));
}

#[test]
fn validation_error_iat_out_of_window() {
let secret = JwtSecret::random();

// Check past 'iat' claim more than 60 secs
let offset = Duration::from_secs(JWT_MAX_IAT_DIFF.as_secs() + 1);
let out_of_window_time = SystemTime::now().checked_sub(offset).unwrap();
let claims = Claims { iat: to_u64(out_of_window_time), exp: Some(10000000000) };
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Err(JwtError::InvalidIssuanceTimestamp)));

// Check future 'iat' claim more than 60 secs
let offset = Duration::from_secs(JWT_MAX_IAT_DIFF.as_secs() + 1);
let out_of_window_time = SystemTime::now().checked_add(offset).unwrap();
let claims = Claims { iat: to_u64(out_of_window_time), exp: Some(10000000000) };
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Err(JwtError::InvalidIssuanceTimestamp)));
}

#[test]
fn validation_error_exp_expired() {
let secret = JwtSecret::random();
let claims = Claims { iat: get_current_timestamp(), exp: Some(1) };
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Err(JwtError::JwtDecodingError(_))));
}

#[test]
fn validation_error_wrong_signature() {
let secret_1 = JwtSecret::random();
let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
let jwt = secret_1.encode(&claims).unwrap();

// A different secret will generate a different signature.
let secret_2 = JwtSecret::random();
let result = secret_2.validate(&jwt);
assert!(matches!(result, Err(JwtError::InvalidSignature)));
}

#[test]
fn validation_error_unsupported_algorithm() {
let secret = JwtSecret::random();
let bytes = &secret.0;

let key = EncodingKey::from_secret(bytes);
let unsupported_algo = Header::new(Algorithm::HS384);

let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
let jwt = encode(&unsupported_algo, &claims, &key).unwrap();
let result = secret.validate(&jwt);

assert!(matches!(result, Err(JwtError::UnsupportedSignatureAlgorithm)));
}

#[test]
fn valid_without_exp_claim() {
let secret = JwtSecret::random();

let claims = Claims { iat: get_current_timestamp(), exp: None };
let jwt = secret.encode(&claims).unwrap();

let result = secret.validate(&jwt);

assert!(matches!(result, Ok(())));
}

fn to_u64(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH).unwrap().as_secs()
}
}
4 changes: 3 additions & 1 deletion crates/rpc-types-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ mod cancun;
mod exit;
mod forkchoice;
mod identification;
mod jwt;
mod optimism;
pub mod payload;
mod transition;

pub use self::{
cancun::*, exit::*, forkchoice::*, identification::*, optimism::*, payload::*, transition::*,
cancun::*, exit::*, forkchoice::*, identification::*, jwt::*, optimism::*, payload::*,
transition::*,
};

#[doc(inline)]
Expand Down

0 comments on commit d68a6b7

Please sign in to comment.