Skip to content

Commit e3498b6

Browse files
committed
feat(shared): jwt lib
1 parent 14f4a50 commit e3498b6

File tree

9 files changed

+1178
-65
lines changed

9 files changed

+1178
-65
lines changed

Cargo.lock

Lines changed: 823 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ base-cli-utils = { path = "crates/shared/cli-utils" }
5959
base-flashtypes = { path = "crates/shared/flashtypes" }
6060
base-primitives = { path = "crates/shared/primitives" }
6161
base-reth-rpc-types = { path = "crates/shared/reth-rpc-types" }
62+
base-jwt = { path = "crates/shared/jwt" }
6263
# Client
6364
base-client-node = { path = "crates/client/node" }
6465
base-metering = { path = "crates/client/metering" }
@@ -190,6 +191,7 @@ op-alloy-rpc-types = { version = "0.22.0", default-features = false }
190191
op-alloy-consensus = { version = "0.22.0", default-features = false }
191192
op-alloy-rpc-jsonrpsee = { version = "0.22.0", default-features = false }
192193
op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false }
194+
op-alloy-provider = { version = "0.22.0", default-features = false }
193195
alloy-op-evm = { version = "0.23.3", default-features = false }
194196
alloy-op-hardforks = "0.4.4"
195197

@@ -198,6 +200,7 @@ op-revm = { version = "12.0.2", default-features = false }
198200

199201
# kona
200202
kona-registry = "0.4.5"
203+
kona-engine = { git = "https://github.com/op-rs/kona", rev = "24e7e2658e09ac00c8e6cbb48bebe6d10f8fb69d" }
201204

202205
# tokio
203206
tokio = "1.48.0"
@@ -208,6 +211,7 @@ tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
208211
futures = "0.3.31"
209212
reqwest = "0.12.25"
210213
futures-util = "0.3.31"
214+
backon = "1.5"
211215

212216
# rpc
213217
jsonrpsee = "0.26.0"

crates/shared/jwt/Cargo.toml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[package]
2+
name = "base-jwt"
3+
description = "JWT secret handling and validation for Base node components"
4+
version.workspace = true
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
license.workspace = true
8+
homepage.workspace = true
9+
repository.workspace = true
10+
11+
[lints]
12+
workspace = true
13+
14+
[features]
15+
test-utils = []
16+
engine-validation = [
17+
"dep:alloy-provider",
18+
"dep:alloy-transport-http",
19+
"dep:backon",
20+
"dep:eyre",
21+
"dep:kona-engine",
22+
"dep:op-alloy-network",
23+
"dep:op-alloy-provider",
24+
"dep:tracing",
25+
"dep:url",
26+
]
27+
28+
[dependencies]
29+
# Core
30+
alloy-rpc-types-engine.workspace = true
31+
alloy-primitives.workspace = true
32+
thiserror.workspace = true
33+
34+
# Optional: engine validation
35+
tracing = { workspace = true, optional = true }
36+
alloy-provider = { workspace = true, optional = true }
37+
alloy-transport-http = { workspace = true, optional = true }
38+
op-alloy-network = { workspace = true, optional = true }
39+
op-alloy-provider = { workspace = true, optional = true }
40+
kona-engine = { workspace = true, optional = true }
41+
backon = { workspace = true, optional = true }
42+
url = { workspace = true, optional = true }
43+
eyre = { workspace = true, optional = true }

crates/shared/jwt/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# `base-jwt`
2+
3+
<a href="https://github.com/base/node-reth/actions/workflows/ci.yml"><img src="https://github.com/base/node-reth/actions/workflows/ci.yml/badge.svg?label=ci" alt="CI"></a>
4+
<a href="https://github.com/base/node-reth/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-d1d1f6.svg?label=license&labelColor=2a2f35" alt="MIT License"></a>
5+
6+
JWT secret handling and validation for Base node components.
7+
8+
## Overview
9+
10+
- **`JwtValidator`**: Validates JWT secrets against an Engine API via capability exchange.
11+
- **`default_jwt_secret`**: Loads a JWT from a file or generates a new random secret.
12+
- **`resolve_jwt_secret`**: Resolves JWT from file path, encoded secret, or default file.
13+
- **`JwtError`**: Errors for loading/parsing JWT secrets.
14+
- **`JwtValidationError`**: Errors during engine API validation.
15+
16+
## Usage
17+
18+
Add the dependency to your `Cargo.toml`:
19+
20+
```toml
21+
[dependencies]
22+
base-jwt = { git = "https://github.com/base/node-reth" }
23+
```
24+
25+
Load a JWT secret:
26+
27+
```rust,ignore
28+
use base_jwt::{JwtSecret, default_jwt_secret, resolve_jwt_secret};
29+
use std::path::Path;
30+
31+
// Load from default file or generate new
32+
let secret = default_jwt_secret("jwt.hex")?;
33+
34+
// Resolve with priority: file > encoded > default
35+
let secret = resolve_jwt_secret(
36+
Some(Path::new("/path/to/jwt.hex")),
37+
None,
38+
"fallback.hex",
39+
)?;
40+
```
41+
42+
With engine validation (requires `engine-validation` feature):
43+
44+
```toml
45+
[dependencies]
46+
base-jwt = { git = "https://github.com/base/node-reth", features = ["engine-validation"] }
47+
```
48+
49+
```rust,ignore
50+
use base_jwt::JwtValidator;
51+
use url::Url;
52+
53+
let validator = JwtValidator::new(jwt_secret);
54+
let validated_secret = validator
55+
.validate_with_engine(Url::parse("http://localhost:8551")?)
56+
.await?;
57+
```
58+
59+
## License
60+
61+
[MIT License](https://github.com/base/node-reth/blob/main/LICENSE)

crates/shared/jwt/src/error.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//! JWT error types.
2+
3+
use thiserror::Error;
4+
5+
/// Errors that occur when loading or parsing JWT secrets.
6+
#[derive(Debug, Error)]
7+
pub enum JwtError {
8+
/// Failed to parse JWT secret from hex.
9+
#[error("Failed to parse JWT secret: {0}")]
10+
ParseError(String),
11+
/// IO error reading/writing JWT file.
12+
#[error("IO error: {0}")]
13+
IoError(String),
14+
}
15+
16+
/// Errors that occur during JWT validation with an engine API.
17+
#[derive(Debug, Error)]
18+
pub enum JwtValidationError {
19+
/// JWT signature is invalid (authentication failed).
20+
#[error("JWT signature is invalid")]
21+
InvalidSignature,
22+
/// Failed to exchange capabilities with engine.
23+
#[error("Failed to exchange capabilities with engine: {0}")]
24+
CapabilityExchange(String),
25+
}

crates/shared/jwt/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#![doc = include_str!("../README.md")]
2+
#![doc(issue_tracker_base_url = "https://github.com/base/node-reth/issues/")]
3+
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
4+
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
5+
6+
mod error;
7+
pub use error::{JwtError, JwtValidationError};
8+
9+
mod secret;
10+
pub use secret::{default_jwt_secret, read_jwt_secret, resolve_jwt_secret};
11+
12+
mod validator;
13+
pub use alloy_rpc_types_engine::JwtSecret;
14+
pub use validator::JwtValidator;
15+
16+
#[cfg(any(test, feature = "test-utils"))]
17+
pub mod test_utils;

crates/shared/jwt/src/secret.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//! JWT secret loading and generation utilities.
2+
3+
use std::{fs::File, io::Write, path::Path};
4+
5+
use alloy_rpc_types_engine::JwtSecret;
6+
7+
use crate::JwtError;
8+
9+
/// Reads a JWT secret from the specified file path.
10+
///
11+
/// The file should contain a hex-encoded JWT secret.
12+
pub fn read_jwt_secret(path: impl AsRef<Path>) -> Result<JwtSecret, JwtError> {
13+
let content = std::fs::read_to_string(path.as_ref())
14+
.map_err(|e| JwtError::IoError(format!("Failed to read JWT secret file: {e}")))?;
15+
JwtSecret::from_hex(content).map_err(|e| JwtError::ParseError(e.to_string()))
16+
}
17+
18+
/// Attempts to read a JWT secret from a file in the current directory.
19+
/// Creates a new random secret if the file doesn't exist.
20+
///
21+
/// # Arguments
22+
/// * `file_name` - The name of the JWT file (e.g., "jwt.hex", "l2_jwt.hex")
23+
pub fn default_jwt_secret(file_name: &str) -> Result<JwtSecret, JwtError> {
24+
let cur_dir = std::env::current_dir()
25+
.map_err(|e| JwtError::IoError(format!("Failed to get current directory: {e}")))?;
26+
27+
std::fs::read_to_string(cur_dir.join(file_name)).map_or_else(
28+
|_| {
29+
let secret = JwtSecret::random();
30+
31+
if let Ok(mut file) = File::create(file_name)
32+
&& let Err(e) =
33+
file.write_all(alloy_primitives::hex::encode(secret.as_bytes()).as_bytes())
34+
{
35+
return Err(JwtError::IoError(format!("Failed to write JWT secret to file: {e}")));
36+
}
37+
38+
Ok(secret)
39+
},
40+
|content| JwtSecret::from_hex(content).map_err(|e| JwtError::ParseError(e.to_string())),
41+
)
42+
}
43+
44+
/// Resolves a JWT secret from multiple sources with priority:
45+
/// 1. File path (if Some)
46+
/// 2. Encoded secret (if Some)
47+
/// 3. Default file in current directory
48+
///
49+
/// # Arguments
50+
/// * `file_path` - Optional path to a JWT file
51+
/// * `encoded` - Optional pre-parsed JwtSecret
52+
/// * `default_file` - Fallback file name in current directory
53+
pub fn resolve_jwt_secret(
54+
file_path: Option<&Path>,
55+
encoded: Option<JwtSecret>,
56+
default_file: &str,
57+
) -> Result<JwtSecret, JwtError> {
58+
if let Some(path) = file_path {
59+
return read_jwt_secret(path);
60+
}
61+
62+
if let Some(secret) = encoded {
63+
return Ok(secret);
64+
}
65+
66+
default_jwt_secret(default_file)
67+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//! Test utilities for JWT handling.
2+
3+
use alloy_rpc_types_engine::JwtSecret;
4+
5+
/// Creates a random JWT secret for testing.
6+
pub fn random_jwt_secret() -> JwtSecret {
7+
JwtSecret::random()
8+
}

crates/shared/jwt/src/validator.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! JWT validation utilities.
2+
3+
use alloy_rpc_types_engine::JwtSecret;
4+
5+
#[cfg(feature = "engine-validation")]
6+
use crate::JwtValidationError;
7+
8+
/// A JWT validator that can verify JWT secrets against an engine API.
9+
#[derive(Debug, Clone, Copy)]
10+
pub struct JwtValidator {
11+
secret: JwtSecret,
12+
}
13+
14+
impl JwtValidator {
15+
/// Creates a new JWT validator with the given secret.
16+
pub const fn new(secret: JwtSecret) -> Self {
17+
Self { secret }
18+
}
19+
20+
/// Returns the underlying JWT secret.
21+
pub const fn secret(&self) -> JwtSecret {
22+
self.secret
23+
}
24+
25+
/// Consumes the validator and returns the JWT secret.
26+
pub const fn into_inner(self) -> JwtSecret {
27+
self.secret
28+
}
29+
30+
/// Check if an error is related to JWT signature validation.
31+
///
32+
/// Walks the error chain to detect JWT authentication failures by
33+
/// looking for common error message patterns.
34+
pub fn is_jwt_signature_error(error: &dyn std::error::Error) -> bool {
35+
let mut source = Some(error);
36+
while let Some(err) = source {
37+
let err_str = err.to_string().to_lowercase();
38+
if err_str.contains("signature invalid")
39+
|| (err_str.contains("jwt") && err_str.contains("invalid"))
40+
|| err_str.contains("unauthorized")
41+
|| err_str.contains("authentication failed")
42+
{
43+
return true;
44+
}
45+
source = err.source();
46+
}
47+
false
48+
}
49+
50+
/// Helper to check JWT signature error from eyre::Error (for retry condition).
51+
#[cfg(feature = "engine-validation")]
52+
pub fn is_jwt_signature_error_from_eyre(error: &eyre::Error) -> bool {
53+
Self::is_jwt_signature_error(error.as_ref() as &dyn std::error::Error)
54+
}
55+
}
56+
57+
#[cfg(feature = "engine-validation")]
58+
impl JwtValidator {
59+
/// Validates the JWT secret by exchanging capabilities with an engine API.
60+
///
61+
/// Uses exponential backoff for transient failures, but fails immediately
62+
/// on authentication errors (invalid JWT signature).
63+
///
64+
/// # Arguments
65+
/// * `engine_url` - The URL of the engine API endpoint
66+
///
67+
/// # Returns
68+
/// * `Ok(JwtSecret)` - The validated JWT secret
69+
/// * `Err(JwtValidationError::InvalidSignature)` - JWT authentication failed
70+
/// * `Err(JwtValidationError::CapabilityExchange(_))` - Transient error after retries
71+
pub async fn validate_with_engine(
72+
self,
73+
engine_url: url::Url,
74+
) -> Result<JwtSecret, JwtValidationError> {
75+
use alloy_provider::RootProvider;
76+
use alloy_transport_http::Http;
77+
use backon::{ExponentialBuilder, Retryable};
78+
use kona_engine::{HyperAuthClient, OpEngineClient};
79+
use op_alloy_network::Optimism;
80+
use op_alloy_provider::ext::engine::OpEngineApi;
81+
use tracing::{debug, error};
82+
83+
let engine = OpEngineClient::<RootProvider, RootProvider<Optimism>>::rpc_client::<Optimism>(
84+
engine_url,
85+
self.secret,
86+
);
87+
88+
let exchange = || async {
89+
match <RootProvider<Optimism> as OpEngineApi<
90+
Optimism,
91+
Http<HyperAuthClient>,
92+
>>::exchange_capabilities(&engine, vec![])
93+
.await
94+
{
95+
Ok(_) => {
96+
debug!("Successfully exchanged capabilities with engine");
97+
Ok(self.secret)
98+
}
99+
Err(e) => {
100+
if Self::is_jwt_signature_error(&e) {
101+
error!(
102+
"Engine API JWT secret differs from the one specified by --l2.jwt-secret/--l2.jwt-secret-encoded"
103+
);
104+
error!(
105+
"Ensure that the JWT secret file specified is correct (by default it is `jwt.hex` in the current directory)"
106+
);
107+
return Err(JwtValidationError::InvalidSignature.into());
108+
}
109+
Err(JwtValidationError::CapabilityExchange(e.to_string()).into())
110+
}
111+
}
112+
};
113+
114+
exchange
115+
.retry(ExponentialBuilder::default())
116+
.when(|e: &eyre::Error| !Self::is_jwt_signature_error_from_eyre(e))
117+
.notify(|_, duration| {
118+
debug!("Retrying engine capability handshake after {duration:?}");
119+
})
120+
.await
121+
.map_err(|e| {
122+
// Convert eyre::Error back to JwtValidationError
123+
if Self::is_jwt_signature_error_from_eyre(&e) {
124+
JwtValidationError::InvalidSignature
125+
} else {
126+
JwtValidationError::CapabilityExchange(e.to_string())
127+
}
128+
})
129+
}
130+
}

0 commit comments

Comments
 (0)