Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
888 changes: 823 additions & 65 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ base-cli-utils = { path = "crates/shared/cli-utils" }
base-flashtypes = { path = "crates/shared/flashtypes" }
base-primitives = { path = "crates/shared/primitives" }
base-reth-rpc-types = { path = "crates/shared/reth-rpc-types" }
base-jwt = { path = "crates/shared/jwt" }
# Client
base-client-node = { path = "crates/client/node" }
base-metering = { path = "crates/client/metering" }
Expand Down Expand Up @@ -190,6 +191,7 @@ op-alloy-rpc-types = { version = "0.22.0", default-features = false }
op-alloy-consensus = { version = "0.22.0", default-features = false }
op-alloy-rpc-jsonrpsee = { version = "0.22.0", default-features = false }
op-alloy-rpc-types-engine = { version = "0.22.0", default-features = false }
op-alloy-provider = { version = "0.22.0", default-features = false }
alloy-op-evm = { version = "0.23.3", default-features = false }
alloy-op-hardforks = "0.4.4"

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

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

# tokio
tokio = "1.48.0"
Expand All @@ -208,6 +211,7 @@ tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
futures = "0.3.31"
reqwest = "0.12.25"
futures-util = "0.3.31"
backon = "1.5"

# rpc
jsonrpsee = "0.26.0"
Expand Down
43 changes: 43 additions & 0 deletions crates/shared/jwt/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[package]
name = "base-jwt"
description = "JWT secret handling and validation for Base node components"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true

[lints]
workspace = true

[features]
test-utils = []
engine-validation = [
"dep:alloy-provider",
"dep:alloy-transport-http",
"dep:backon",
"dep:eyre",
"dep:kona-engine",
"dep:op-alloy-network",
"dep:op-alloy-provider",
"dep:tracing",
"dep:url",
]

[dependencies]
# Core
alloy-rpc-types-engine.workspace = true
alloy-primitives.workspace = true
thiserror.workspace = true

# Optional: engine validation
tracing = { workspace = true, optional = true }
alloy-provider = { workspace = true, optional = true }
alloy-transport-http = { workspace = true, optional = true }
op-alloy-network = { workspace = true, optional = true }
op-alloy-provider = { workspace = true, optional = true }
kona-engine = { workspace = true, optional = true }
backon = { workspace = true, optional = true }
url = { workspace = true, optional = true }
eyre = { workspace = true, optional = true }
61 changes: 61 additions & 0 deletions crates/shared/jwt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# `base-jwt`

<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>
<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>

JWT secret handling and validation for Base node components.

## Overview

- **`JwtValidator`**: Validates JWT secrets against an Engine API via capability exchange.
- **`default_jwt_secret`**: Loads a JWT from a file or generates a new random secret.
- **`resolve_jwt_secret`**: Resolves JWT from file path, encoded secret, or default file.
- **`JwtError`**: Errors for loading/parsing JWT secrets.
- **`JwtValidationError`**: Errors during engine API validation.

## Usage

Add the dependency to your `Cargo.toml`:

```toml
[dependencies]
base-jwt = { git = "https://github.com/base/node-reth" }
```

Load a JWT secret:

```rust,ignore
use base_jwt::{JwtSecret, default_jwt_secret, resolve_jwt_secret};
use std::path::Path;

// Load from default file or generate new
let secret = default_jwt_secret("jwt.hex")?;

// Resolve with priority: file > encoded > default
let secret = resolve_jwt_secret(
Some(Path::new("/path/to/jwt.hex")),
None,
"fallback.hex",
)?;
```

With engine validation (requires `engine-validation` feature):

```toml
[dependencies]
base-jwt = { git = "https://github.com/base/node-reth", features = ["engine-validation"] }
```

```rust,ignore
use base_jwt::JwtValidator;
use url::Url;

let validator = JwtValidator::new(jwt_secret);
let validated_secret = validator
.validate_with_engine(Url::parse("http://localhost:8551")?)
.await?;
```

## License

[MIT License](https://github.com/base/node-reth/blob/main/LICENSE)
25 changes: 25 additions & 0 deletions crates/shared/jwt/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! JWT error types.

use thiserror::Error;

/// Errors that occur when loading or parsing JWT secrets.
#[derive(Debug, Error)]
pub enum JwtError {
/// Failed to parse JWT secret from hex.
#[error("Failed to parse JWT secret: {0}")]
ParseError(String),
/// IO error reading/writing JWT file.
#[error("IO error: {0}")]
IoError(String),
}

/// Errors that occur during JWT validation with an engine API.
#[derive(Debug, Error)]
pub enum JwtValidationError {
/// JWT signature is invalid (authentication failed).
#[error("JWT signature is invalid")]
InvalidSignature,
/// Failed to exchange capabilities with engine.
#[error("Failed to exchange capabilities with engine: {0}")]
CapabilityExchange(String),
}
17 changes: 17 additions & 0 deletions crates/shared/jwt/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/base/node-reth/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![cfg_attr(not(test), warn(unused_crate_dependencies))]

mod error;
pub use error::{JwtError, JwtValidationError};

mod secret;
pub use secret::{default_jwt_secret, read_jwt_secret, resolve_jwt_secret};

mod validator;
pub use alloy_rpc_types_engine::JwtSecret;
pub use validator::JwtValidator;

#[cfg(any(test, feature = "test-utils"))]
pub mod test_utils;
67 changes: 67 additions & 0 deletions crates/shared/jwt/src/secret.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! JWT secret loading and generation utilities.

use std::{fs::File, io::Write, path::Path};

use alloy_rpc_types_engine::JwtSecret;

use crate::JwtError;

/// Reads a JWT secret from the specified file path.
///
/// The file should contain a hex-encoded JWT secret.
pub fn read_jwt_secret(path: impl AsRef<Path>) -> Result<JwtSecret, JwtError> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| JwtError::IoError(format!("Failed to read JWT secret file: {e}")))?;
JwtSecret::from_hex(content).map_err(|e| JwtError::ParseError(e.to_string()))
}

/// Attempts to read a JWT secret from a file in the current directory.
/// Creates a new random secret if the file doesn't exist.
///
/// # Arguments
/// * `file_name` - The name of the JWT file (e.g., "jwt.hex", "l2_jwt.hex")
pub fn default_jwt_secret(file_name: &str) -> Result<JwtSecret, JwtError> {
let cur_dir = std::env::current_dir()
.map_err(|e| JwtError::IoError(format!("Failed to get current directory: {e}")))?;

std::fs::read_to_string(cur_dir.join(file_name)).map_or_else(
|_| {
let secret = JwtSecret::random();

if let Ok(mut file) = File::create(file_name)
&& let Err(e) =
file.write_all(alloy_primitives::hex::encode(secret.as_bytes()).as_bytes())
{
return Err(JwtError::IoError(format!("Failed to write JWT secret to file: {e}")));
}

Ok(secret)
},
|content| JwtSecret::from_hex(content).map_err(|e| JwtError::ParseError(e.to_string())),
)
}

/// Resolves a JWT secret from multiple sources with priority:
/// 1. File path (if Some)
/// 2. Encoded secret (if Some)
/// 3. Default file in current directory
///
/// # Arguments
/// * `file_path` - Optional path to a JWT file
/// * `encoded` - Optional pre-parsed JwtSecret
/// * `default_file` - Fallback file name in current directory
pub fn resolve_jwt_secret(
file_path: Option<&Path>,
encoded: Option<JwtSecret>,
default_file: &str,
) -> Result<JwtSecret, JwtError> {
if let Some(path) = file_path {
return read_jwt_secret(path);
}

if let Some(secret) = encoded {
return Ok(secret);
}

default_jwt_secret(default_file)
}
8 changes: 8 additions & 0 deletions crates/shared/jwt/src/test_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Test utilities for JWT handling.

use alloy_rpc_types_engine::JwtSecret;

/// Creates a random JWT secret for testing.
pub fn random_jwt_secret() -> JwtSecret {
JwtSecret::random()
}
130 changes: 130 additions & 0 deletions crates/shared/jwt/src/validator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! JWT validation utilities.

use alloy_rpc_types_engine::JwtSecret;

#[cfg(feature = "engine-validation")]
use crate::JwtValidationError;

/// A JWT validator that can verify JWT secrets against an engine API.
#[derive(Debug, Clone, Copy)]
pub struct JwtValidator {
secret: JwtSecret,
}

impl JwtValidator {
/// Creates a new JWT validator with the given secret.
pub const fn new(secret: JwtSecret) -> Self {
Self { secret }
}

/// Returns the underlying JWT secret.
pub const fn secret(&self) -> JwtSecret {
self.secret
}

/// Consumes the validator and returns the JWT secret.
pub const fn into_inner(self) -> JwtSecret {
self.secret
}

/// Check if an error is related to JWT signature validation.
///
/// Walks the error chain to detect JWT authentication failures by
/// looking for common error message patterns.
pub fn is_jwt_signature_error(error: &dyn std::error::Error) -> bool {
let mut source = Some(error);
while let Some(err) = source {
let err_str = err.to_string().to_lowercase();
if err_str.contains("signature invalid")
|| (err_str.contains("jwt") && err_str.contains("invalid"))
|| err_str.contains("unauthorized")
|| err_str.contains("authentication failed")
{
return true;
}
source = err.source();
}
false
}

/// Helper to check JWT signature error from eyre::Error (for retry condition).
#[cfg(feature = "engine-validation")]
pub fn is_jwt_signature_error_from_eyre(error: &eyre::Error) -> bool {
Self::is_jwt_signature_error(error.as_ref() as &dyn std::error::Error)
}
}

#[cfg(feature = "engine-validation")]
impl JwtValidator {
/// Validates the JWT secret by exchanging capabilities with an engine API.
///
/// Uses exponential backoff for transient failures, but fails immediately
/// on authentication errors (invalid JWT signature).
///
/// # Arguments
/// * `engine_url` - The URL of the engine API endpoint
///
/// # Returns
/// * `Ok(JwtSecret)` - The validated JWT secret
/// * `Err(JwtValidationError::InvalidSignature)` - JWT authentication failed
/// * `Err(JwtValidationError::CapabilityExchange(_))` - Transient error after retries
pub async fn validate_with_engine(
self,
engine_url: url::Url,
) -> Result<JwtSecret, JwtValidationError> {
use alloy_provider::RootProvider;
use alloy_transport_http::Http;
use backon::{ExponentialBuilder, Retryable};
use kona_engine::{HyperAuthClient, OpEngineClient};
use op_alloy_network::Optimism;
use op_alloy_provider::ext::engine::OpEngineApi;
use tracing::{debug, error};

let engine = OpEngineClient::<RootProvider, RootProvider<Optimism>>::rpc_client::<Optimism>(
engine_url,
self.secret,
);

let exchange = || async {
match <RootProvider<Optimism> as OpEngineApi<
Optimism,
Http<HyperAuthClient>,
>>::exchange_capabilities(&engine, vec![])
.await
{
Ok(_) => {
debug!("Successfully exchanged capabilities with engine");
Ok(self.secret)
}
Err(e) => {
if Self::is_jwt_signature_error(&e) {
error!(
"Engine API JWT secret differs from the one specified by --l2.jwt-secret/--l2.jwt-secret-encoded"
);
error!(
"Ensure that the JWT secret file specified is correct (by default it is `jwt.hex` in the current directory)"
);
return Err(JwtValidationError::InvalidSignature.into());
}
Err(JwtValidationError::CapabilityExchange(e.to_string()).into())
}
}
};

exchange
.retry(ExponentialBuilder::default())
.when(|e: &eyre::Error| !Self::is_jwt_signature_error_from_eyre(e))
.notify(|_, duration| {
debug!("Retrying engine capability handshake after {duration:?}");
})
.await
.map_err(|e| {
// Convert eyre::Error back to JwtValidationError
if Self::is_jwt_signature_error_from_eyre(&e) {
JwtValidationError::InvalidSignature
} else {
JwtValidationError::CapabilityExchange(e.to_string())
}
})
}
}
Loading