alloy_rpc_types_engine/
jwt.rs

1//! JWT (JSON Web Token) utilities for the Engine API.
2
3use alloc::string::String;
4use alloy_primitives::hex;
5use core::{str::FromStr, time::Duration};
6use jsonwebtoken::get_current_timestamp;
7use rand::Rng;
8
9#[cfg(feature = "std")]
10use std::{
11    fs, io,
12    path::{Path, PathBuf},
13};
14
15#[cfg(feature = "serde")]
16use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, Validation};
17
18/// Errors returned by the [`JwtSecret`]
19#[derive(Debug, derive_more::Display)]
20pub enum JwtError {
21    /// An error encountered while decoding the hexadecimal string for the JWT secret.
22    #[display("{_0}")]
23    JwtSecretHexDecodeError(hex::FromHexError),
24
25    /// The JWT key length provided is invalid, expecting a specific length.
26    #[display("JWT key is expected to have a length of {_0} digits. {_1} digits key provided")]
27    InvalidLength(usize, usize),
28
29    /// The signature algorithm used in the JWT is not supported. Only HS256 is supported.
30    #[display("unsupported signature algorithm. Only HS256 is supported")]
31    UnsupportedSignatureAlgorithm,
32
33    /// The provided signature in the JWT is invalid.
34    #[display("provided signature is invalid")]
35    InvalidSignature,
36
37    /// The "iat" (issued-at) claim in the JWT is not within the allowed ±60 seconds from the
38    /// current time.
39    #[display("IAT (issued-at) claim is not within ±60 seconds from the current time")]
40    InvalidIssuanceTimestamp,
41
42    /// The Authorization header is missing or invalid in the context of JWT validation.
43    #[display("Authorization header is missing or invalid")]
44    MissingOrInvalidAuthorizationHeader,
45
46    /// An error occurred during JWT decoding.
47    #[display("JWT decoding error: {_0}")]
48    JwtDecodingError(String),
49
50    /// An error occurred while creating a directory to store the JWT.
51    #[display("failed to create dir {path:?}: {source}")]
52    #[cfg(feature = "std")]
53    CreateDir {
54        /// The source `io::Error`.
55        source: io::Error,
56        /// The path related to the operation.
57        path: PathBuf,
58    },
59
60    /// An error occurred while reading the JWT from a file.
61    #[display("failed to read from {path:?}: {source}")]
62    #[cfg(feature = "std")]
63    Read {
64        /// The source `io::Error`.
65        source: io::Error,
66        /// The path related to the operation.
67        path: PathBuf,
68    },
69
70    /// An error occurred while writing the JWT to a file.
71    #[display("failed to write to {path:?}: {source}")]
72    #[cfg(feature = "std")]
73    Write {
74        /// The source `io::Error`.
75        source: io::Error,
76        /// The path related to the operation.
77        path: PathBuf,
78    },
79}
80
81impl From<hex::FromHexError> for JwtError {
82    fn from(err: hex::FromHexError) -> Self {
83        Self::JwtSecretHexDecodeError(err)
84    }
85}
86
87#[cfg(feature = "std")]
88impl std::error::Error for JwtError {
89    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
90        match self {
91            Self::JwtSecretHexDecodeError(err) => Some(err),
92            Self::CreateDir { source, .. }
93            | Self::Read { source, .. }
94            | Self::Write { source, .. } => Some(source),
95            _ => None,
96        }
97    }
98}
99
100/// Length of the hex-encoded 256 bit secret key.
101/// A 256-bit encoded string in Rust has a length of 64 digits because each digit represents 4 bits
102/// of data. In hexadecimal representation, each digit can have 16 possible values (0-9 and A-F), so
103/// 4 bits can be represented using a single hex digit. Therefore, to represent a 256-bit string,
104/// we need 64 hexadecimal digits (256 bits ÷ 4 bits per digit = 64 digits).
105const JWT_SECRET_LEN: usize = 64;
106
107/// The JWT `iat` (issued-at) claim cannot exceed +-60 seconds from the current time.
108const JWT_MAX_IAT_DIFF: Duration = Duration::from_secs(60);
109
110/// The execution layer client MUST support at least the following alg HMAC + SHA256 (HS256)
111#[cfg(feature = "serde")]
112const JWT_SIGNATURE_ALGO: Algorithm = Algorithm::HS256;
113
114/// Claims in JWT are used to represent a set of information about an entity.
115///
116/// Claims are essentially key-value pairs that are encoded as JSON objects and included in the
117/// payload of a JWT. They are used to transmit information such as the identity of the entity, the
118/// time the JWT was issued, and the expiration time of the JWT, among others.
119///
120/// The Engine API spec requires that just the `iat` (issued-at) claim is provided.
121/// It ignores claims that are optional or additional for this specification.
122#[derive(Copy, Clone, Debug)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub struct Claims {
125    /// The "iat" value MUST be a number containing a NumericDate value.
126    /// According to the RFC A NumericDate represents the number of seconds since
127    /// the UNIX_EPOCH.
128    /// - [`RFC-7519 - Spec`](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6)
129    /// - [`RFC-7519 - Notations`](https://www.rfc-editor.org/rfc/rfc7519#section-2)
130    pub iat: u64,
131    /// The "exp" (expiration time) claim identifies the expiration time on or after which the JWT
132    /// MUST NOT be accepted for processing.
133    pub exp: Option<u64>,
134}
135
136impl Claims {
137    /// Creates a new instance of [`Claims`] with the current timestamp as the `iat` claim.
138    pub fn with_current_timestamp() -> Self {
139        Self { iat: get_current_timestamp(), exp: None }
140    }
141
142    /// Checks if the `iat` claim is within the allowed range from the current time.
143    pub fn is_within_time_window(&self) -> bool {
144        let now_secs = get_current_timestamp();
145        now_secs.abs_diff(self.iat) <= JWT_MAX_IAT_DIFF.as_secs()
146    }
147}
148
149impl Default for Claims {
150    /// By default, the `iat` claim is set to the current timestamp.
151    fn default() -> Self {
152        Self::with_current_timestamp()
153    }
154}
155
156/// Value-object holding a reference to a hex-encoded 256-bit secret key.
157///
158/// A JWT secret key is used to secure JWT-based authentication. The secret key is
159/// a shared secret between the server and the client and is used to calculate a digital signature
160/// for the JWT, which is included in the JWT along with its payload.
161///
162/// See also: [Secret key - Engine API specs](https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#key-distribution)
163#[derive(Copy, Clone, PartialEq, Eq)]
164pub struct JwtSecret([u8; 32]);
165
166impl JwtSecret {
167    /// Creates an instance of [`JwtSecret`].
168    ///
169    /// Returns an error if one of the following applies:
170    /// - `hex` is not a valid hexadecimal string
171    /// - `hex` argument length is less than `JWT_SECRET_LEN`
172    ///
173    /// This strips the leading `0x`, if any.
174    pub fn from_hex<S: AsRef<str>>(hex: S) -> Result<Self, JwtError> {
175        let hex = hex.as_ref().trim();
176        match hex::decode_to_array(hex) {
177            Ok(b) => Ok(Self(b)),
178            Err(hex::FromHexError::InvalidStringLength | hex::FromHexError::OddLength) => {
179                Err(JwtError::InvalidLength(JWT_SECRET_LEN, hex.len()))
180            }
181            Err(e) => Err(JwtError::JwtSecretHexDecodeError(e)),
182        }
183    }
184
185    /// Tries to load a [`JwtSecret`] from the specified file path.
186    /// I/O or secret validation errors might occur during read operations in the form of
187    /// a [`JwtError`].
188    #[cfg(feature = "std")]
189    pub fn from_file(fpath: &Path) -> Result<Self, JwtError> {
190        fs::read_to_string(fpath)
191            .map_err(|err| JwtError::Read { source: err, path: fpath.into() })
192            .and_then(Self::from_hex)
193    }
194
195    /// Creates a random [`JwtSecret`] and tries to store it at the specified path. I/O errors might
196    /// occur during write operations in the form of a [`JwtError`]
197    #[cfg(feature = "std")]
198    pub fn try_create_random(fpath: &Path) -> Result<Self, JwtError> {
199        if let Some(dir) = fpath.parent() {
200            // Create parent directory
201            fs::create_dir_all(dir)
202                .map_err(|err| JwtError::CreateDir { source: err, path: dir.into() })?
203        }
204
205        let secret = Self::random();
206        let bytes = &secret.0;
207        let hex = hex::encode(bytes);
208        fs::write(fpath, hex).map_err(|err| JwtError::Write { source: err, path: fpath.into() })?;
209        Ok(secret)
210    }
211
212    /// Validates a JWT token along the following rules:
213    /// - The JWT signature is valid.
214    /// - The JWT is signed with the `HMAC + SHA256 (HS256)` algorithm.
215    /// - The JWT `iat` (issued-at) claim is a timestamp within +-60 seconds from the current time.
216    /// - The JWT `exp` (expiration time) claim is validated by default if defined.
217    ///
218    /// See also: [JWT Claims - Engine API specs](https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#jwt-claims)
219    #[cfg(feature = "serde")]
220    pub fn validate(&self, jwt: &str) -> Result<(), JwtError> {
221        // Create a new validation object with the required signature algorithm
222        // and ensure that the `iat` claim is present. The `exp` claim is validated if defined.
223        let mut validation = Validation::new(JWT_SIGNATURE_ALGO);
224        validation.set_required_spec_claims(&["iat"]);
225        let bytes = &self.0;
226
227        match jsonwebtoken::decode::<Claims>(jwt, &DecodingKey::from_secret(bytes), &validation) {
228            Ok(token) => {
229                if !token.claims.is_within_time_window() {
230                    Err(JwtError::InvalidIssuanceTimestamp)?
231                }
232            }
233            Err(err) => match *err.kind() {
234                ErrorKind::InvalidSignature => Err(JwtError::InvalidSignature)?,
235                ErrorKind::InvalidAlgorithm => Err(JwtError::UnsupportedSignatureAlgorithm)?,
236                _ => {
237                    let detail = format!("{err}");
238                    Err(JwtError::JwtDecodingError(detail))?
239                }
240            },
241        };
242
243        Ok(())
244    }
245
246    /// Generates a random [`JwtSecret`] containing a hex-encoded 256 bit secret key.
247    pub fn random() -> Self {
248        Self(rand::thread_rng().gen())
249    }
250
251    /// Encode the header and claims given and sign the payload using the algorithm from the header
252    /// and the key.
253    #[cfg(feature = "serde")]
254    pub fn encode(&self, claims: &Claims) -> Result<String, jsonwebtoken::errors::Error> {
255        let bytes = &self.0;
256        let key = jsonwebtoken::EncodingKey::from_secret(bytes);
257        let algo = jsonwebtoken::Header::new(Algorithm::HS256);
258        jsonwebtoken::encode(&algo, claims, &key)
259    }
260
261    /// Returns the secret key as a byte slice.
262    pub const fn as_bytes(&self) -> &[u8] {
263        &self.0
264    }
265}
266
267impl core::fmt::Debug for JwtSecret {
268    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
269        f.debug_tuple("JwtSecretHash").field(&"{{}}").finish()
270    }
271}
272
273impl FromStr for JwtSecret {
274    type Err = JwtError;
275
276    fn from_str(s: &str) -> Result<Self, Self::Err> {
277        Self::from_hex(s)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use assert_matches::assert_matches;
285    use jsonwebtoken::{encode, EncodingKey, Header};
286    use similar_asserts::assert_eq;
287    #[cfg(feature = "std")]
288    use std::time::{Duration, SystemTime, UNIX_EPOCH};
289    use tempfile::tempdir;
290
291    #[test]
292    fn from_hex() {
293        let key = "f79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";
294        let secret_0: Result<JwtSecret, _> = JwtSecret::from_hex(key);
295        assert!(secret_0.is_ok());
296
297        let key = "0xf79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";
298        let secret_1: Result<JwtSecret, _> = JwtSecret::from_hex(key);
299        assert!(secret_1.is_ok());
300
301        let key = "0xf79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430 ";
302        let secret_2: Result<JwtSecret, _> = JwtSecret::from_hex(key);
303        assert!(secret_2.is_ok());
304
305        assert_eq!(secret_0.as_ref().unwrap().clone(), secret_1.unwrap());
306        assert_eq!(secret_0.unwrap(), secret_2.unwrap());
307    }
308
309    #[test]
310    fn original_key_integrity_across_transformations() {
311        let original = "f79ae8046bc11c9927afe911db7143c51a806c4a537cc08e0d37140b0192f430";
312        let secret = JwtSecret::from_hex(original).unwrap();
313        let bytes = &secret.0;
314        let computed = hex::encode(bytes);
315        assert_eq!(original, computed);
316    }
317
318    #[test]
319    fn secret_has_64_hex_digits() {
320        let expected_len = 64;
321        let secret = JwtSecret::random();
322        let hex = hex::encode(secret.0);
323        assert_eq!(hex.len(), expected_len);
324    }
325
326    #[test]
327    fn creation_ok_hex_string_with_0x() {
328        let hex: String =
329            "0x7365637265747365637265747365637265747365637265747365637265747365".into();
330        let result = JwtSecret::from_hex(hex);
331        assert!(result.is_ok());
332    }
333
334    #[test]
335    fn creation_error_wrong_len() {
336        let hex = "f79ae8046";
337        let result = JwtSecret::from_hex(hex);
338        assert!(matches!(result, Err(JwtError::InvalidLength(_, _))));
339    }
340
341    #[test]
342    fn creation_error_wrong_hex_string() {
343        let hex: String = "This__________Is__________Not_______An____Hex_____________String".into();
344        let result = JwtSecret::from_hex(hex);
345        assert!(matches!(result, Err(JwtError::JwtSecretHexDecodeError(_))));
346    }
347
348    #[test]
349    #[cfg(feature = "serde")]
350    fn validation_ok() {
351        let secret = JwtSecret::random();
352        let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
353        let jwt = secret.encode(&claims).unwrap();
354
355        let result = secret.validate(&jwt);
356
357        assert!(matches!(result, Ok(())));
358    }
359
360    #[test]
361    #[cfg(feature = "serde")]
362    fn validation_with_current_time_ok() {
363        let secret = JwtSecret::random();
364        let claims = Claims::default();
365        let jwt = secret.encode(&claims).unwrap();
366
367        let result = secret.validate(&jwt);
368
369        assert!(matches!(result, Ok(())));
370    }
371
372    #[test]
373    #[cfg(all(feature = "std", feature = "serde"))]
374    fn validation_error_iat_out_of_window() {
375        let secret = JwtSecret::random();
376
377        // Check past 'iat' claim more than 60 secs
378        // Use a larger margin to avoid timing flakiness
379        let offset = Duration::from_secs(JWT_MAX_IAT_DIFF.as_secs() + 10);
380        let out_of_window_time = SystemTime::now().checked_sub(offset).unwrap();
381        let claims = Claims { iat: to_u64(out_of_window_time), exp: Some(10000000000) };
382        let jwt = secret.encode(&claims).unwrap();
383
384        let result = secret.validate(&jwt);
385
386        assert!(matches!(result, Err(JwtError::InvalidIssuanceTimestamp)));
387
388        // Check future 'iat' claim more than 60 secs
389        // Use a larger margin to avoid timing flakiness
390        let offset = Duration::from_secs(JWT_MAX_IAT_DIFF.as_secs() + 10);
391        let out_of_window_time = SystemTime::now().checked_add(offset).unwrap();
392        let claims = Claims { iat: to_u64(out_of_window_time), exp: Some(10000000000) };
393        let jwt = secret.encode(&claims).unwrap();
394
395        let result = secret.validate(&jwt);
396
397        assert!(matches!(result, Err(JwtError::InvalidIssuanceTimestamp)));
398    }
399
400    #[test]
401    #[cfg(feature = "serde")]
402    fn validation_error_exp_expired() {
403        let secret = JwtSecret::random();
404        let claims = Claims { iat: get_current_timestamp(), exp: Some(1) };
405        let jwt = secret.encode(&claims).unwrap();
406
407        let result = secret.validate(&jwt);
408
409        assert!(matches!(result, Err(JwtError::JwtDecodingError(_))));
410    }
411
412    #[test]
413    #[cfg(feature = "serde")]
414    fn validation_error_wrong_signature() {
415        let secret_1 = JwtSecret::random();
416        let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
417        let jwt = secret_1.encode(&claims).unwrap();
418
419        // A different secret will generate a different signature.
420        let secret_2 = JwtSecret::random();
421        let result = secret_2.validate(&jwt);
422        assert!(matches!(result, Err(JwtError::InvalidSignature)));
423    }
424
425    #[test]
426    #[cfg(feature = "serde")]
427    fn validation_error_unsupported_algorithm() {
428        let secret = JwtSecret::random();
429        let bytes = &secret.0;
430
431        let key = EncodingKey::from_secret(bytes);
432        let unsupported_algo = Header::new(Algorithm::HS384);
433
434        let claims = Claims { iat: get_current_timestamp(), exp: Some(10000000000) };
435        let jwt = encode(&unsupported_algo, &claims, &key).unwrap();
436        let result = secret.validate(&jwt);
437
438        assert!(matches!(result, Err(JwtError::UnsupportedSignatureAlgorithm)));
439    }
440
441    #[test]
442    #[cfg(feature = "serde")]
443    fn valid_without_exp_claim() {
444        let secret = JwtSecret::random();
445
446        let claims = Claims { iat: get_current_timestamp(), exp: None };
447        let jwt = secret.encode(&claims).unwrap();
448
449        let result = secret.validate(&jwt);
450
451        assert!(matches!(result, Ok(())));
452    }
453
454    #[test]
455    #[cfg(feature = "std")]
456    fn ephemeral_secret_created() {
457        let fpath: &Path = Path::new("secret0.hex");
458        assert!(fs::metadata(fpath).is_err());
459        JwtSecret::try_create_random(fpath).expect("A secret file should be created");
460        assert!(fs::metadata(fpath).is_ok());
461        fs::remove_file(fpath).unwrap();
462    }
463
464    #[test]
465    #[cfg(feature = "std")]
466    fn valid_secret_provided() {
467        let fpath = Path::new("secret1.hex");
468        assert!(fs::metadata(fpath).is_err());
469
470        let secret = JwtSecret::random();
471        fs::write(fpath, hex(&secret)).unwrap();
472
473        match JwtSecret::from_file(fpath) {
474            Ok(gen_secret) => {
475                fs::remove_file(fpath).unwrap();
476                assert_eq!(hex(&gen_secret), hex(&secret));
477            }
478            Err(_) => {
479                fs::remove_file(fpath).unwrap();
480            }
481        }
482    }
483
484    #[test]
485    #[cfg(feature = "std")]
486    fn invalid_hex_provided() {
487        let fpath = Path::new("secret2.hex");
488        fs::write(fpath, "invalid hex").unwrap();
489        let result = JwtSecret::from_file(fpath);
490        assert!(result.is_err());
491        fs::remove_file(fpath).unwrap();
492    }
493
494    #[test]
495    #[cfg(feature = "std")]
496    fn provided_file_not_exists() {
497        let fpath = Path::new("secret3.hex");
498        let result = JwtSecret::from_file(fpath);
499        assert_matches!(result, Err(JwtError::Read {source: _,path }) if path == fpath.to_path_buf());
500        assert!(fs::metadata(fpath).is_err());
501    }
502
503    #[test]
504    #[cfg(feature = "std")]
505    fn provided_file_is_a_directory() {
506        let dir = tempdir().unwrap();
507        let result = JwtSecret::from_file(dir.path());
508        assert_matches!(result, Err(JwtError::Read {source: _,path}) if path == dir.path());
509    }
510
511    #[cfg(feature = "std")]
512    fn to_u64(time: SystemTime) -> u64 {
513        time.duration_since(UNIX_EPOCH).unwrap().as_secs()
514    }
515
516    fn hex(secret: &JwtSecret) -> String {
517        hex::encode(secret.0)
518    }
519}