diff --git a/bolt-cli/Cargo.lock b/bolt-cli/Cargo.lock
index 6f7692d1..4a111268 100644
--- a/bolt-cli/Cargo.lock
+++ b/bolt-cli/Cargo.lock
@@ -1359,6 +1359,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
[[package]]
name = "cipher"
version = "0.3.0"
@@ -2167,8 +2173,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -2391,6 +2399,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
+ "webpki-roots",
]
[[package]]
@@ -3511,6 +3520,58 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+[[package]]
+name = "quinn"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef"
+dependencies = [
+ "bytes",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.0",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.3",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
+dependencies = [
+ "bytes",
+ "getrandom",
+ "rand",
+ "ring 0.17.8",
+ "rustc-hash 2.1.0",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.3",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "quote"
version = "1.0.37"
@@ -3637,7 +3698,10 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
+ "quinn",
+ "rustls",
"rustls-pemfile",
+ "rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3645,11 +3709,13 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
+ "tokio-rustls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+ "webpki-roots",
"windows-registry",
]
@@ -3829,6 +3895,9 @@ name = "rustls-pki-types"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
+dependencies = [
+ "web-time",
+]
[[package]]
name = "rustls-webpki"
@@ -5041,6 +5110,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.7"
diff --git a/bolt-cli/Cargo.toml b/bolt-cli/Cargo.toml
index e88d1559..6c46c4cf 100644
--- a/bolt-cli/Cargo.toml
+++ b/bolt-cli/Cargo.toml
@@ -38,7 +38,7 @@ thiserror = "1.0"
hex = "0.4.3"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
-reqwest = "0.12.8"
+reqwest = { version = "0.12.9", features = ["rustls-tls"] }
rand = "0.8.5"
[dev-dependencies]
diff --git a/bolt-cli/README.md b/bolt-cli/README.md
index 65facf53..e1bf3e0f 100644
--- a/bolt-cli/README.md
+++ b/bolt-cli/README.md
@@ -42,11 +42,12 @@ Available commands:
The `delegate` command generates signed delegation messages for the Constraints API.
To learn more about the Constraints API, please refer to the [Bolt documentation][bolt-docs].
-The `delegate` command supports three key sources:
+The `delegate` command supports different key sources:
- Local BLS secret keys (as hex-encoded strings) via `secret-keys`
- Local EIP-2335 filesystem keystore directories via `local-keystore`
- Remote Dirk keystore via `dirk` (requires TLS credentials)
+- Remote Web3Signer keystore via `web3signer`
Usage
@@ -62,6 +63,7 @@ Commands:
secret-keys Use local secret keys to generate the signed messages
local-keystore Use an EIP-2335 filesystem keystore directory to generate the signed messages
dirk Use a remote DIRK keystore to generate the signed messages
+web3signer Use a remote web3signer keystore to generate the signed messages
help Print this message or the help of the given subcommand(s)
Options:
@@ -133,6 +135,17 @@ bolt delegate \
--wallet-path wallet1 --passphrases secret
```
+4. Generating a delegation using a remote Web3Signer keystore
+
+```text
+bolt delegate \
+ --delegatee-pubkey 0x83eeddfac5e60f8fe607ee8713efb8877c295ad9f8ca075f4d8f6f2ae241a30dd57f78f6f3863a9fe0d5b5db9d550b93 \
+ --chain holesky \
+ web3signer --url https://localhost:9000 \
+ --ca-cert-path ./test_data/web3signer/tls/web3signer.crt \
+ --combined_pem_path ./test_data/web3signer/tls/combined.pem
+```
+
---
@@ -144,6 +157,7 @@ The `pubkeys` command lists available BLS public keys from different key sources
- Local BLS secret keys (as hex-encoded strings) via `secret-keys`
- Local EIP-2335 filesystem keystore directories via `local-keystore`
- Remote Dirk keystore via `dirk` (requires TLS credentials)
+- Remote Web3Signer via `web3signer`
Usage
@@ -159,6 +173,7 @@ Commands:
secret-keys Use local secret keys to generate the signed messages
local-keystore Use an EIP-2335 filesystem keystore directory to generate the signed messages
dirk Use a remote DIRK keystore to generate the signed messages
+ web3signer Use a remote web3signer keystore to generate the signed messages
help Print this message or the help of the given subcommand(s)
Options:
@@ -195,6 +210,14 @@ bolt pubkeys dirk --url https://localhost:9091 \
--wallet-path wallet1 --passphrases secret
```
+4. Listing BLS public keys from a remote Web3Signer keystore
+
+```text
+bolt pubkeys web3signer --url https://localhost:9000 \
+ --ca-cert-path ./test_data/web3signer/tls/web3signer.crt \
+ --combined_pem_path ./test_data/web3signer/tls/combined.pem
+```
+
---
diff --git a/bolt-cli/src/cli.rs b/bolt-cli/src/cli.rs
index 64190493..4bcf16c7 100644
--- a/bolt-cli/src/cli.rs
+++ b/bolt-cli/src/cli.rs
@@ -354,6 +354,14 @@ pub enum KeysSource {
#[clap(flatten)]
opts: DirkOpts,
},
+
+ /// Use a remote web3signer keystore as source for the public keys.
+ #[clap(name = "web3signer")]
+ Web3Signer {
+ /// The options for connecting to the web3signer keystore.
+ #[clap(flatten)]
+ opts: Web3SignerOpts,
+ },
}
#[derive(Debug, Clone, Parser)]
@@ -379,6 +387,13 @@ pub enum SecretsSource {
#[clap(flatten)]
opts: DirkOpts,
},
+
+ /// Use a remote Web3Signer keystore to generate the signed messages.
+ #[clap(name = "web3signer")]
+ Web3Signer {
+ #[clap(flatten)]
+ opts: Web3SignerOpts,
+ },
}
/// Options for reading a keystore folder.
@@ -426,12 +441,35 @@ pub struct DirkOpts {
/// The TLS credentials for connecting to the DIRK keystore.
#[clap(flatten)]
- pub tls_credentials: TlsCredentials,
+ pub tls_credentials: DirkTlsCredentials,
+}
+
+/// Options for connecting to a Web3Signer keystore.
+#[derive(Debug, Clone, Parser)]
+pub struct Web3SignerOpts {
+ /// The URL of the Web3Signer keystore.
+ #[clap(long, env = "WEB3SIGNER_URL")]
+ pub url: String,
+
+ /// The TLS credentials for connecting to the Web3Signer keystore.
+ #[clap(flatten)]
+ pub tls_credentials: Web3SignerTlsCredentials,
+}
+
+/// TLS credentials for connecting to a remote Web3Signer server.
+#[derive(Debug, Clone, PartialEq, Eq, Parser)]
+pub struct Web3SignerTlsCredentials {
+ /// Path to the CA certificate file. (.crt)
+ #[clap(long, env = "CA_CERT_PATH")]
+ pub ca_cert_path: String,
+ /// Path to the PEM encoded private key and certificate file. (.pem)
+ #[clap(long, env = "CLIENT_COMBINED_PEM_PATH")]
+ pub combined_pem_path: String,
}
-/// TLS credentials for connecting to a remote server.
+/// TLS credentials for connecting to a remote Dirk server.
#[derive(Debug, Clone, PartialEq, Eq, Parser)]
-pub struct TlsCredentials {
+pub struct DirkTlsCredentials {
/// Path to the client certificate file. (.crt)
#[clap(long, env = "CLIENT_CERT_PATH")]
pub client_cert_path: String,
diff --git a/bolt-cli/src/commands/delegate/mod.rs b/bolt-cli/src/commands/delegate/mod.rs
index 68dbaf38..6fe44c77 100644
--- a/bolt-cli/src/commands/delegate/mod.rs
+++ b/bolt-cli/src/commands/delegate/mod.rs
@@ -18,6 +18,9 @@ mod keystore;
/// Create delegations from remote Dirk signers.
mod dirk;
+/// Create delegations from remote Web3Signers.
+mod web3signer;
+
impl DelegateCommand {
/// Run the `delegate` command.
pub async fn run(self) -> Result<()> {
@@ -46,6 +49,10 @@ impl DelegateCommand {
let delegatee_pubkey = parse_bls_public_key(&self.delegatee_pubkey)?;
dirk::generate_from_dirk(opts, delegatee_pubkey, self.chain, self.action).await?
}
+ SecretsSource::Web3Signer { opts } => {
+ let delegatee_pubkey = parse_bls_public_key(&self.delegatee_pubkey)?;
+ web3signer::generate_from_web3signer(opts, delegatee_pubkey, self.action).await?
+ }
};
debug!("Generated {} signed messages", signed_messages.len());
diff --git a/bolt-cli/src/commands/delegate/web3signer.rs b/bolt-cli/src/commands/delegate/web3signer.rs
new file mode 100644
index 00000000..f0c98fe8
--- /dev/null
+++ b/bolt-cli/src/commands/delegate/web3signer.rs
@@ -0,0 +1,109 @@
+use crate::{
+ cli::{Action, Web3SignerOpts},
+ commands::delegate::types::{
+ DelegationMessage, RevocationMessage, SignedDelegation, SignedRevocation,
+ },
+ common::web3signer::Web3Signer,
+};
+use ethereum_consensus::crypto::{PublicKey as BlsPublicKey, Signature as BlsSignature};
+use eyre::Result;
+use tracing::debug;
+
+use super::types::SignedMessage;
+
+/// Generate signed delegations/recovations using a remote Web3Signer.
+pub async fn generate_from_web3signer(
+ opts: Web3SignerOpts,
+ delegatee_pubkey: BlsPublicKey,
+ action: Action,
+) -> Result> {
+ // Connect to web3signer.
+ let mut web3signer = Web3Signer::connect(opts.url, opts.tls_credentials).await?;
+
+ // Read in the accounts from the remote keystore.
+ let accounts = web3signer.list_accounts().await?;
+ debug!("Found {} remote accounts to sign with", accounts.len());
+
+ let mut signed_messages = Vec::with_capacity(accounts.len());
+
+ for account in accounts {
+ // Parse the BLS key of the account.
+ // Trim the pre-pended 0x.
+ let trimmed_account = trim_hex_prefix(&account)?;
+ let pubkey = BlsPublicKey::try_from(hex::decode(trimmed_account)?.as_slice())?;
+
+ match action {
+ Action::Delegate => {
+ let message = DelegationMessage::new(pubkey.clone(), delegatee_pubkey.clone());
+ // Web3Signer expects the pre-pended 0x.
+ let signing_root = format!("0x{}", &hex::encode(message.digest()));
+ let returned_signature =
+ web3signer.request_signature(&account, &signing_root).await?;
+ // Trim the 0x.
+ let trimmed_signature = trim_hex_prefix(&returned_signature)?;
+ let signature = BlsSignature::try_from(hex::decode(trimmed_signature)?.as_slice())?;
+ let signed = SignedDelegation { message, signature };
+ signed_messages.push(SignedMessage::Delegation(signed));
+ }
+ Action::Revoke => {
+ let message = RevocationMessage::new(pubkey.clone(), delegatee_pubkey.clone());
+ // Web3Signer expects the pre-pended 0x.
+ let signing_root = format!("0x{}", &hex::encode(message.digest()));
+ let returned_signature =
+ web3signer.request_signature(&account, &signing_root).await?;
+ // Trim the 0x.
+ let trimmed_signature = trim_hex_prefix(&returned_signature)?;
+ let signature = BlsSignature::try_from(trimmed_signature.as_bytes())?;
+ let signed = SignedRevocation { message, signature };
+ signed_messages.push(SignedMessage::Revocation(signed));
+ }
+ }
+ }
+
+ Ok(signed_messages)
+}
+
+/// A utility function to trim the pre-pended 0x prefix for hex strings.
+fn trim_hex_prefix(hex: &str) -> Result {
+ let trimmed = hex.get(2..).ok_or_else(|| eyre::eyre!("Invalid hex string: {hex}"))?;
+ Ok(trimmed.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{
+ cli::{Action, Chain, Web3SignerOpts},
+ commands::delegate::web3signer::generate_from_web3signer,
+ common::{parse_bls_public_key, web3signer::test_util::start_web3signer_test_server},
+ };
+
+ /// Test generating signed delegations using a remote Web3Signer signer.
+ ///
+ /// ```shell
+ /// cargo test --package bolt --bin bolt -- commands::delegate::web3signer::tests::test_delegation_web3signer
+ /// --exact --show-output --ignored --nocapture
+ /// ```
+ #[tokio::test]
+ #[ignore = "Requires Web3Signer to be installed on the system"]
+ async fn test_delegation_web3signer() -> eyre::Result<()> {
+ let _ = tracing_subscriber::fmt::try_init();
+ let (url, mut web3signer_proc, creds) = start_web3signer_test_server().await?;
+
+ let delegatee_pubkey = "0x83eeddfac5e60f8fe607ee8713efb8877c295ad9f8ca075f4d8f6f2ae241a30dd57f78f6f3863a9fe0d5b5db9d550b93";
+ let delegatee_pubkey = parse_bls_public_key(delegatee_pubkey)?;
+ let chain = Chain::Mainnet;
+
+ let opts = Web3SignerOpts { url, tls_credentials: creds };
+
+ let signed_delegations =
+ generate_from_web3signer(opts, delegatee_pubkey, Action::Delegate).await?;
+
+ let signed_message = signed_delegations.first().expect("to get signed delegation");
+
+ signed_message.verify_signature(chain)?;
+
+ web3signer_proc.kill()?;
+
+ Ok(())
+ }
+}
diff --git a/bolt-cli/src/commands/pubkeys.rs b/bolt-cli/src/commands/pubkeys.rs
index 1d74ec38..d618c6e7 100644
--- a/bolt-cli/src/commands/pubkeys.rs
+++ b/bolt-cli/src/commands/pubkeys.rs
@@ -7,6 +7,7 @@ use crate::{
common::{
dirk::Dirk,
keystore::{keystore_paths, KeystoreError},
+ web3signer::Web3Signer,
write_to_file,
},
pb::eth2_signer_api::ListAccountsResponse,
@@ -41,6 +42,15 @@ impl PubkeysCommand {
write_to_file(&self.out, &pubkeys)?;
println!("Pubkeys generated from Dirk and saved to {}", self.out);
}
+ KeysSource::Web3Signer { opts } => {
+ let mut web3signer = Web3Signer::connect(opts.url, opts.tls_credentials).await?;
+
+ let accounts = web3signer.list_accounts().await?;
+ let pubkeys = list_from_web3signer_accounts(&accounts)?;
+
+ write_to_file(&self.out, &pubkeys)?;
+ println!("Pubkeys generated from Web3signer and saved to {}", self.out);
+ }
}
Ok(())
@@ -95,3 +105,16 @@ pub fn list_from_dirk_accounts(accounts: ListAccountsResponse) -> Result Result> {
+ let mut pubkeys = Vec::with_capacity(accounts.len());
+
+ for acc in accounts {
+ let trimmed_account = &acc.clone()[2..];
+ let pubkey = BlsPublicKey::try_from(hex::decode(trimmed_account)?.as_slice())?;
+ pubkeys.push(pubkey);
+ }
+
+ Ok(pubkeys)
+}
diff --git a/bolt-cli/src/common/dirk/distributed.rs b/bolt-cli/src/common/dirk/distributed.rs
index 26dfa8c6..6103bef9 100644
--- a/bolt-cli/src/common/dirk/distributed.rs
+++ b/bolt-cli/src/common/dirk/distributed.rs
@@ -5,7 +5,7 @@ use futures::{stream, StreamExt};
use tracing::{debug, warn};
use crate::{
- cli::TlsCredentials,
+ cli::DirkTlsCredentials,
common::dirk::recover_signature::recover_signature_from_shards,
pb::eth2_signer_api::{self, Endpoint},
};
@@ -14,7 +14,7 @@ use super::Dirk;
#[derive(Debug)]
pub struct DistributedDirkAccount {
- credentials: TlsCredentials,
+ credentials: DirkTlsCredentials,
participants: Vec,
threshold: usize,
composite_public_key: BlsPublicKey,
@@ -24,7 +24,7 @@ impl DistributedDirkAccount {
/// Create a new distributed account.
pub fn new(
acc: eth2_signer_api::DistributedAccount,
- credentials: TlsCredentials,
+ credentials: DirkTlsCredentials,
) -> Result {
let composite_public_key = BlsPublicKey::try_from(acc.composite_public_key.as_ref())?;
diff --git a/bolt-cli/src/common/dirk/mod.rs b/bolt-cli/src/common/dirk/mod.rs
index 53e5acdf..939ab434 100644
--- a/bolt-cli/src/common/dirk/mod.rs
+++ b/bolt-cli/src/common/dirk/mod.rs
@@ -7,7 +7,7 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
use tracing::debug;
use crate::{
- cli::TlsCredentials,
+ cli::DirkTlsCredentials,
pb::eth2_signer_api::{
AccountManagerClient, ListAccountsRequest, ListAccountsResponse, ListerClient,
LockAccountRequest, ResponseState, SignRequest, SignRequestId, SignerClient,
@@ -42,7 +42,7 @@ pub struct Dirk {
impl Dirk {
/// Connect to the DIRK server with the given address and TLS credentials.
- pub async fn connect(addr: String, credentials: TlsCredentials) -> Result {
+ pub async fn connect(addr: String, credentials: DirkTlsCredentials) -> Result {
let addr = addr.parse()?;
let tls_config = compose_credentials(credentials)?;
let conn = Channel::builder(addr).tls_config(tls_config)?.connect().await?;
@@ -169,8 +169,8 @@ impl Dirk {
}
}
-/// Compose the TLS credentials from the given paths.
-fn compose_credentials(creds: TlsCredentials) -> Result {
+/// Compose the TLS credentials for Dirk from the given paths.
+fn compose_credentials(creds: DirkTlsCredentials) -> Result {
let client_cert = fs::read(creds.client_cert_path).wrap_err("Failed to read client cert")?;
let client_key = fs::read(creds.client_key_path).wrap_err("Failed to read client key")?;
diff --git a/bolt-cli/src/common/dirk/test_util.rs b/bolt-cli/src/common/dirk/test_util.rs
index 0cb1e7c9..e90ebf6d 100644
--- a/bolt-cli/src/common/dirk/test_util.rs
+++ b/bolt-cli/src/common/dirk/test_util.rs
@@ -6,7 +6,7 @@ use std::{
time::Duration,
};
-use crate::cli::TlsCredentials;
+use crate::cli::DirkTlsCredentials;
/// Initialize the default TLS provider for the tests if not already set.
pub fn try_init_tls_provider() {
@@ -20,7 +20,7 @@ pub fn try_init_tls_provider() {
/// This is a single instance (non distributed).
///
/// Returns the DIRK client URL and credentials, and the corresponding server process handle.
-pub async fn start_single_dirk_test_server() -> eyre::Result<(String, TlsCredentials, Child)> {
+pub async fn start_single_dirk_test_server() -> eyre::Result<(String, DirkTlsCredentials, Child)> {
try_init_tls_provider();
// Check if dirk is installed (in $PATH)
@@ -47,7 +47,7 @@ pub async fn start_single_dirk_test_server() -> eyre::Result<(String, TlsCredent
let url = "https://localhost:9091".to_string();
- let cred = TlsCredentials {
+ let cred = DirkTlsCredentials {
client_cert_path: test_data_dir.clone() + "/client1.crt",
client_key_path: test_data_dir.clone() + "/client1.key",
ca_cert_path: Some(test_data_dir.clone() + "/security/ca.crt"),
@@ -71,7 +71,8 @@ pub async fn start_single_dirk_test_server() -> eyre::Result<(String, TlsCredent
///
/// This is because we need to map 3 different server certificates to localhost
/// to simulate multiple servers with their own hostnames.
-pub async fn start_multi_dirk_test_server() -> eyre::Result<(String, TlsCredentials, Vec)> {
+pub async fn start_multi_dirk_test_server() -> eyre::Result<(String, DirkTlsCredentials, Vec)>
+{
try_init_tls_provider();
// Check if dirk is installed (in $PATH)
@@ -107,7 +108,7 @@ pub async fn start_multi_dirk_test_server() -> eyre::Result<(String, TlsCredenti
// Note: the first server is used for the client connection
let url = "https://localhost-1:8881".to_string();
- let cred = TlsCredentials {
+ let cred = DirkTlsCredentials {
client_cert_path: test_data_dir.clone() + "/client/localhost.crt",
client_key_path: test_data_dir.clone() + "/client/localhost.key",
ca_cert_path: Some(test_data_dir.clone() + "/1/security/ca.crt"),
diff --git a/bolt-cli/src/common/mod.rs b/bolt-cli/src/common/mod.rs
index dfd67941..a1f23b6d 100644
--- a/bolt-cli/src/common/mod.rs
+++ b/bolt-cli/src/common/mod.rs
@@ -20,6 +20,9 @@ pub mod signing;
/// Utilities for hashing messages and custom types.
pub mod hash;
+/// Utilities for working with Consensys' Web3Signer remote keystore.
+pub mod web3signer;
+
/// Parse a BLS public key from a string
pub fn parse_bls_public_key(delegatee_pubkey: &str) -> Result {
let hex_pk = delegatee_pubkey.strip_prefix("0x").unwrap_or(delegatee_pubkey);
diff --git a/bolt-cli/src/common/web3signer.rs b/bolt-cli/src/common/web3signer.rs
new file mode 100644
index 00000000..0d24e97a
--- /dev/null
+++ b/bolt-cli/src/common/web3signer.rs
@@ -0,0 +1,200 @@
+use std::fs;
+
+use eyre::{Context, Result};
+use reqwest::{Certificate, Identity, Url};
+use serde::{Deserialize, Serialize};
+
+use crate::cli::Web3SignerTlsCredentials;
+
+/// Web3Signer remote server.
+///
+/// Functionality:
+/// - List consensus accounts in the keystore.
+/// - Sign roots over the consensus type.
+///
+/// Reference: https://docs.web3signer.consensys.io/reference
+#[derive(Clone)]
+pub struct Web3Signer {
+ base_url: Url,
+ client: reqwest::Client,
+}
+
+impl Web3Signer {
+ /// Establish connection to a remote Web3Signer instance with TLS credentials.
+ pub async fn connect(addr: String, credentials: Web3SignerTlsCredentials) -> Result {
+ let base_url = addr.parse()?;
+ let (cert, identity) = compose_credentials(credentials)?;
+
+ let client = reqwest::Client::builder()
+ .add_root_certificate(cert)
+ .identity(identity)
+ .use_rustls_tls()
+ .build()?;
+
+ Ok(Self { base_url, client })
+ }
+
+ /// List the consensus accounts of the keystore.
+ ///
+ /// Only the consensus keys are returned.
+ /// This is due to signing only being over the consensus type.
+ ///
+ /// Reference: https://commit-boost.github.io/commit-boost-client/api/
+ pub async fn list_accounts(&mut self) -> Result> {
+ let path = self.base_url.join("/signer/v1/get_pubkeys")?;
+ let resp = self.client.get(path).send().await?.json::().await?;
+
+ let consensus_keys: Vec =
+ resp.keys.into_iter().map(|key_set| key_set.consensus).collect();
+
+ Ok(consensus_keys)
+ }
+
+ /// Request a signature from the remote signer.
+ ///
+ /// This will sign an arbituary root over the consensus type.
+ ///
+ /// Reference: https://commit-boost.github.io/commit-boost-client/api/
+ pub async fn request_signature(&mut self, pub_key: &str, object_root: &str) -> Result {
+ let path = self.base_url.join("/signer/v1/request_signature")?;
+ let body = CommitBoostSignatureRequest {
+ type_: "consensus".to_string(),
+ pubkey: pub_key.to_string(),
+ object_root: object_root.to_string(),
+ };
+
+ let resp = self.client.post(path).json(&body).send().await?.json::().await?;
+
+ Ok(resp)
+ }
+}
+
+/// Compose the TLS credentials for the Web3Signer.
+///
+/// Returns the CA certificate and the identity (combined PEM).
+fn compose_credentials(credentials: Web3SignerTlsCredentials) -> Result<(Certificate, Identity)> {
+ let ca_cert = fs::read(credentials.ca_cert_path).wrap_err("Failed to read CA cert")?;
+ let ca_cert = Certificate::from_pem(&ca_cert)?;
+
+ let identity = fs::read(credentials.combined_pem_path).wrap_err("Failed to read PEM")?;
+ let identity = Identity::from_pem(&identity)?;
+
+ Ok((ca_cert, identity))
+}
+
+#[derive(Serialize, Deserialize)]
+struct Keys {
+ /// The consensus keys stored in the Web3Signer.
+ pub consensus: String,
+ /// The two below proxy fields are here for deserialisation purposes.
+ /// They are not used as signing is only over the consensus type.
+ #[allow(unused)]
+ pub proxy_bls: Vec,
+ #[allow(unused)]
+ pub proxy_ecdsa: Vec,
+}
+
+/// Outer container for response.
+#[derive(Serialize, Deserialize)]
+struct CommitBoostKeys {
+ keys: Vec,
+}
+
+/// Request signature from the Web3Signer.
+#[derive(Serialize, Deserialize)]
+struct CommitBoostSignatureRequest {
+ #[serde(rename = "type")]
+ pub type_: String,
+ pub pubkey: String,
+ pub object_root: String,
+}
+
+#[cfg(test)]
+pub mod test_util {
+ use std::{
+ process::{Child, Command},
+ time::Duration,
+ };
+
+ use crate::cli::Web3SignerTlsCredentials;
+ use eyre::{bail, Ok};
+
+ /// Start a Web3Signer server for testing.
+ ///
+ /// This will start a Web3Signer server and return its URL, process handle, and TLS credentials.
+ pub async fn start_web3signer_test_server(
+ ) -> eyre::Result<(String, Child, Web3SignerTlsCredentials)> {
+ let test_data_dir = env!("CARGO_MANIFEST_DIR").to_string() + "/test_data/web3signer";
+
+ // Keystore test data.
+ let keystore_dir = test_data_dir.clone() + "/keystore";
+
+ // TLS test data.
+ let tls_dir = test_data_dir.clone() + "/tls";
+ let tls_keystore = tls_dir.clone() + "/key.p12";
+ let tls_password = tls_dir.clone() + "/password.txt";
+ let ca_cert_path = tls_dir.clone() + "/web3signer.crt";
+ let combined_pem_path = tls_dir.clone() + "/combined.pem";
+
+ // Check if web3signer is installed (in $PATH).
+ if Command::new("web3signer").spawn().is_err() {
+ bail!("Web3Signer is not installed in $PATH");
+ }
+
+ // Start the web3signer server.
+ let web3signer_proc = Command::new("web3signer")
+ .arg("--key-store-path")
+ .arg(keystore_dir.clone())
+ .arg("--tls-keystore-file")
+ .arg(tls_keystore)
+ .arg("--tls-allow-any-client")
+ .arg("true")
+ .arg("--tls-keystore-password-file")
+ .arg(tls_password.clone())
+ .arg("eth2")
+ .arg("--network")
+ .arg("mainnet")
+ .arg("--slashing-protection-enabled")
+ .arg("false")
+ .arg("--commit-boost-api-enabled")
+ .arg("true")
+ .arg("--proxy-keystores-path")
+ .arg(keystore_dir.clone())
+ .arg("--proxy-keystores-password-file")
+ .arg(tls_password)
+ .spawn()?;
+
+ // Allow the server to start up.
+ tokio::time::sleep(Duration::from_secs(5)).await;
+
+ let credentials = Web3SignerTlsCredentials { ca_cert_path, combined_pem_path };
+ let url = "https://127.0.0.1:9000".to_string();
+
+ Ok((url, web3signer_proc, credentials))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Test for connecting to the Web3Signer and listing accounts.
+ ///
+ /// ```shell
+ /// cargo test --package bolt --bin bolt -- common::web3signer::tests::test_web3signer_connection_e2e
+ /// --exact --show-output --ignored
+ /// ```
+ #[tokio::test]
+ #[ignore = "Requires Web3Signer to be installed on the system"]
+ async fn test_web3signer_connection_e2e() -> eyre::Result<()> {
+ let (url, mut web3signer_proc, creds) = test_util::start_web3signer_test_server().await?;
+ let mut web3signer = Web3Signer::connect(url, creds).await?;
+
+ let accounts = web3signer.list_accounts().await?;
+ println!("Web3Signer Accounts: {:?}", accounts);
+
+ web3signer_proc.kill()?;
+
+ Ok(())
+ }
+}
diff --git a/bolt-cli/test_data/web3signer/keystore/keystore-m_12381_3600_0_0_0-1733041966.json b/bolt-cli/test_data/web3signer/keystore/keystore-m_12381_3600_0_0_0-1733041966.json
new file mode 100644
index 00000000..5303dfb2
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/keystore/keystore-m_12381_3600_0_0_0-1733041966.json
@@ -0,0 +1 @@
+{"crypto": {"kdf": {"function": "scrypt", "params": {"dklen": 32, "n": 262144, "r": 8, "p": 1, "salt": "622f70de380282d6bcacea3a70154d990a2367bae7560ebe07f2357907fbeb2b"}, "message": ""}, "checksum": {"function": "sha256", "params": {}, "message": "463544f14320114a2be597f43f7da2bbf758f17d19af5b961c90925533b6b42d"}, "cipher": {"function": "aes-128-ctr", "params": {"iv": "3de45e2f98b2c29cf988d69ede95d9ab"}, "message": "8c18c17bb9a185e6a72c246bee6f3efcf263b96dccc2e78fa382b8984e1f3311"}}, "description": "", "pubkey": "967dbf953e4e93f11fa23f45ca4f6eb092cad028786bd507557bd0d1053116183093ba7dfdcbfee742af37606f5458eb", "path": "m/12381/3600/0/0/0", "uuid": "258d5f1f-116c-4ae9-9ead-ecfcb3daa6a3", "version": 4}
\ No newline at end of file
diff --git a/bolt-cli/test_data/web3signer/keystore/password.txt b/bolt-cli/test_data/web3signer/keystore/password.txt
new file mode 100644
index 00000000..830f5817
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/keystore/password.txt
@@ -0,0 +1 @@
+testdata
\ No newline at end of file
diff --git a/bolt-cli/test_data/web3signer/keystore/validators.yaml b/bolt-cli/test_data/web3signer/keystore/validators.yaml
new file mode 100644
index 00000000..1d2035ab
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/keystore/validators.yaml
@@ -0,0 +1,4 @@
+type: "file-keystore"
+#pool shield render south used december repair injury gym surface extra true chat narrow carry lizard body table borrow ripple swallow soda any vehicle
+keystoreFile: "./keystore-m_12381_3600_0_0_0-1733041966.json"
+keystorePasswordFile: "./password.txt"
\ No newline at end of file
diff --git a/bolt-cli/test_data/web3signer/tls/combined.pem b/bolt-cli/test_data/web3signer/tls/combined.pem
new file mode 100644
index 00000000..b37613b8
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/tls/combined.pem
@@ -0,0 +1,83 @@
+-----BEGIN CERTIFICATE-----
+MIIFVzCCAz+gAwIBAgIUS6WAuJjrNKU6qzOR3/cxTooU9/IwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDEwMjIwOTA2MjFaFw0yOTEw
+MjEwOTA2MjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEB
+BQADggIPADCCAgoCggIBANyTDRfa37FhTG2qEPckYPR6vP1tFcUqatK1V5mhKk7p
+U38dF5YtCwuNX0FH/iG4fe7w3Xavt86aVVOpUF/Vxy3becv+RThpmUbRzCN/FlfT
+ZzxsEMN/VFABRarxQK24UCbjnQ7p/yHiXOeRE5pAgBanp5uKKkSq+qEo9sb7s0w9
+c6ifgXQR/GuX68yui1XiJgE3WlkTk/hvKbbOAAhuzE0x9atndbFfBRMO1Sz7zScH
+HF0cp/YI2SRx5cx7ovj9biOR0hi0Af+DuHT15vznDkfMo4sAT28CeBjtXBM1eYY9
+cm6gD7zm35+kcD2xulVkw4a/AueBLOwiszclnIKULC1t4ubuwYx6Be2uGrwBy2x3
+sJh8NoMVqGqZLfVmZ7qpah1iBMsuGhH+NjZtdiKeFtU2KhOkbpUlSVHCoL3Qn8xY
+eytg7IqyfXZ0YSVnE3YsAJDCOjh1vQXTK0FMkt4z4uaM8UepG5ucurhQpGcEbcSj
+DLxLbHMlR4dMFNlR825OPBBVpNLxXyfsHNuftJmX0OziSS2glVjYERqRWpkglgh2
+EHL2P2R63LRMNr9qKJBdZbHzschURvcZLuNr8dqkCS83vtOhHhCH9Hyl+QZC9w+F
+MgIgdbxyE0iSUwVu2gwWqoPLC9gf0rssLxySkaKXUEizpbcuyb9qnT/JlNxahaDd
+AgMBAAGjcDBuMB8GA1UdIwQYMBaAFO3Lnl1Id4OcqYwhtgbFXrx6min4MAkGA1Ud
+EwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4E
+FgQUWlWshAUj6JXYhYHPlSKkBCx2A9swDQYJKoZIhvcNAQELBQADggIBACuvuqA1
+aL+iiyZw8DUnPgBFHoFRbViLKLxg6Qiqv/ldvoLYKYzE5rlCuSoSRoh2cd7Du3i8
+66cRrp8eh70XiHM5P7034BdYzIo7oCHfEmVPJpo1+G85+WbNzTf2ZLNFGoeudOGw
+Kvwkxb4gr2xX03EOms/w2EyVycdQ0u7Y0yaDluDhXQF2ynJYnXDGx5oIrwp9YQWF
+neGSyCTP4A67QybKYaA1p1Jibhv3i4QPcFHFOSOFOidqWn01zY/TS0kxQvFpX9cB
+7LjMTujHiTdZGZVHFAf9gAlMXQ1jvYdUf9r1qOXvnBOLI/aqV5fLKqgXXmqt5WFZ
+bhtbUVhEu4OEavgboPOt3WUpbXSnHyVR7t/f/wtycOnfTcYcQb2dZAGRyLP/cVQt
+nDBElrpUHizysNuNMa1rfd/3WC0FvC0/M2OwNK1ad9itctGKel0UrTvCU59/GO3C
+P6Q2EFxyQc1wAgekkBZQX5se9RcTo8VJlV16ZMlo4tm3l57YtHwipKCVVf7EUgxu
+Bsj21nBY5nOIHzaYAqF5Bz7/Lg/9IFuVx/sPatcMYmUcYkPSvm/1uRB17lr30sQp
+bOKGbxzv00JFBwF0t6ZO/XnpLQiHLBtbwgGKMUak0/wZiFiZPmcdaljHLqq08fPV
+S3zuTPjwU2aaQsAqAu9F/B1Cn2/gFiJEL/Mg
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+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
+nJqYM6m13Q+F0Wu8krDMA65Wi5nA
+-----END PRIVATE KEY-----
diff --git a/bolt-cli/test_data/web3signer/tls/key.p12 b/bolt-cli/test_data/web3signer/tls/key.p12
new file mode 100644
index 00000000..792dc197
Binary files /dev/null and b/bolt-cli/test_data/web3signer/tls/key.p12 differ
diff --git a/bolt-cli/test_data/web3signer/tls/password.txt b/bolt-cli/test_data/web3signer/tls/password.txt
new file mode 100644
index 00000000..0a47d365
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/tls/password.txt
@@ -0,0 +1 @@
+meow
\ No newline at end of file
diff --git a/bolt-cli/test_data/web3signer/tls/web3signer.crt b/bolt-cli/test_data/web3signer/tls/web3signer.crt
new file mode 100644
index 00000000..676c8115
--- /dev/null
+++ b/bolt-cli/test_data/web3signer/tls/web3signer.crt
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+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
+-----END CERTIFICATE-----
\ No newline at end of file