From cfa4668952c59d8b7eb11c6881a12afa5e274729 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Wed, 10 Jun 2026 10:30:14 +0200 Subject: [PATCH 1/3] feat(asb): authenticate the RPC server with a cookie token The ASB JSON-RPC server generates a random token on startup, writes it to a .cookie file in the data directory, and requires it as an HTTP Bearer token. asb-controller reads the cookie via --cookie and attaches the header; a new `cookie` subcommand prints the token. The orchestrator mounts the asb-data volume into the controller and passes --cookie. --- Cargo.lock | 4 ++++ swap-asb/src/main.rs | 1 + swap-controller-api/src/lib.rs | 2 ++ swap-controller/src/cli.rs | 7 +++++++ swap-controller/src/main.rs | 31 +++++++++++++++++++++++++++---- swap-orchestrator/src/compose.rs | 11 +++++++++++ swap/Cargo.toml | 2 ++ swap/src/asb/rpc/server.rs | 32 +++++++++++++++++++++++++++++++- swap/tests/harness/mod.rs | 17 +++++++++++++++++ 9 files changed, 102 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1cfdd81ee..b89c441b00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10668,6 +10668,8 @@ dependencies = [ "tor-hsservice", "tor-llcrypto", "tor-rtcompat", + "tower", + "tower-http", "tracing", "tracing-appender", "tracing-ext", @@ -13092,12 +13094,14 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "base64 0.22.1", "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", "iri-string", + "mime", "pin-project-lite", "tower", "tower-layer", diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index 5450533800..57b1489566 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -391,6 +391,7 @@ pub async fn main() -> Result<()> { let rpc_server = RpcServer::start( host, port, + &config.data.dir, bitcoin_wallet.clone(), monero_wallet.clone(), event_loop_service, diff --git a/swap-controller-api/src/lib.rs b/swap-controller-api/src/lib.rs index e3a61f7f9a..c5d8bd9ecd 100644 --- a/swap-controller-api/src/lib.rs +++ b/swap-controller-api/src/lib.rs @@ -3,6 +3,8 @@ use jsonrpsee::types::ErrorObjectOwned; use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub const RPC_COOKIE_FILE_NAME: &str = ".cookie"; + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BitcoinBalanceResponse { #[serde(with = "bitcoin::amount::serde::as_sat")] diff --git a/swap-controller/src/cli.rs b/swap-controller/src/cli.rs index 2f7caa9c17..1c2b5d0bff 100644 --- a/swap-controller/src/cli.rs +++ b/swap-controller/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use std::path::PathBuf; use uuid::Uuid; #[derive(Parser)] @@ -9,6 +10,10 @@ pub struct Cli { #[arg(long, default_value = "http://127.0.0.1:9944")] pub url: String, + /// Path to the RPC auth cookie file written by the ASB + #[arg(long)] + pub cookie: PathBuf, + /// Command to execute (defaults to interactive shell if omitted) #[command(subcommand)] pub cmd: Option, @@ -16,6 +21,8 @@ pub struct Cli { #[derive(Subcommand, Clone)] pub enum Cmd { + /// Print the RPC auth cookie token + Cookie, /// Check connection to ASB server CheckConnection, /// Get Bitcoin balance diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 108015a529..7b01537fac 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -1,20 +1,40 @@ mod cli; mod repl; +use anyhow::Context; use clap::Parser; use cli::{Cli, Cmd}; +use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClientBuilder}; use swap_controller_api::{AsbApiClient, MoneroSeedResponse}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let client = jsonrpsee::http_client::HttpClientBuilder::default().build(&cli.url)?; + let token = std::fs::read_to_string(&cli.cookie) + .with_context(|| format!("Failed to read RPC cookie file at {}", cli.cookie.display()))?; + let token = token.trim().to_string(); + + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}")) + .context("Cookie token is not a valid HTTP header value")?, + ); + let client = HttpClientBuilder::default() + .set_headers(headers) + .build(&cli.url)?; match cli.cmd { - None => repl::run(client, dispatch).await?, + None => { + repl::run(client, move |cmd, client| { + let token = token.clone(); + async move { dispatch(cmd, client, token).await } + }) + .await? + } Some(cmd) => { - if let Err(e) = dispatch(cmd.clone(), client.clone()).await { + if let Err(e) = dispatch(cmd.clone(), client.clone(), token).await { eprintln!("Command failed with error: {e:?}"); } } @@ -23,8 +43,11 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { +async fn dispatch(cmd: Cmd, client: impl AsbApiClient, token: String) -> anyhow::Result<()> { match cmd { + Cmd::Cookie => { + println!("{token}"); + } Cmd::CheckConnection => { client.check_connection().await?; println!("Connected"); diff --git a/swap-orchestrator/src/compose.rs b/swap-orchestrator/src/compose.rs index abf6254fa0..0417a97964 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -15,6 +15,7 @@ pub const DOCKER_LOG_MAX_FILE: &str = "5"; pub const ASB_DATA_DIR: &str = "/asb-data"; pub const ASB_CONFIG_FILE: &str = "config.toml"; +pub const ASB_RPC_COOKIE_FILE: &str = ".cookie"; pub const DOCKER_COMPOSE_FILE: &str = "./docker-compose.yml"; pub const PROMTAIL_CONFIG_FILE: &str = "./promtail.yml"; pub const PROMETHEUS_CONFIG_FILE: &str = "./prometheus.yml"; @@ -297,6 +298,14 @@ fn build(input: OrchestratorInput) -> String { let command_asb_controller = command![ "asb-controller", flag!("--url=http://asb:{}", input.ports.asb_rpc_port), + flag!( + "--cookie={}", + input + .directories + .asb_data_dir + .join(ASB_RPC_COOKIE_FILE) + .display() + ), ]; let command_asb_tracing_logger = command![ @@ -567,6 +576,8 @@ services: logging: *default-logging depends_on: - asb + volumes: + - 'asb-data:{asb_data_dir}:ro' entrypoint: '' command: {command_asb_controller} asb-tracing-logger: diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 6460241288..400ef320d8 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -91,6 +91,8 @@ http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } prometheus-client = "0.22" +tower = "0.5" +tower-http = { version = "0.6", features = ["auth", "validate-request"] } # Tokio tokio = { workspace = true, features = ["process", "fs", "net", "parking_lot", "rt"] } diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index 7ed89e10cc..b55d5436ca 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -6,9 +6,12 @@ use bitcoin_wallet::BitcoinWallet; use jsonrpsee::server::{ServerBuilder, ServerHandle}; use jsonrpsee::types::ErrorObjectOwned; use jsonrpsee::types::error::ErrorCode; +use rand::distributions::{Alphanumeric, DistString}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::{Decimal, RoundingStrategy}; +use std::path::Path; use std::sync::Arc; +use tower_http::validate_request::ValidateRequestHeaderLayer; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, ExternalBitcoinRedeemAddressResponse, MoneroAddressResponse, MoneroBalanceResponse, @@ -29,12 +32,21 @@ impl RpcServer { pub async fn start( host: String, port: u16, + data_dir: &Path, bitcoin_wallet: Arc, monero_wallet: Arc, event_loop_service: EventLoopService, db: Arc, ) -> Result { + let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + let cookie_path = data_dir.join(swap_controller_api::RPC_COOKIE_FILE_NAME); + write_cookie(&cookie_path, &token)?; + + let http_middleware = + tower::ServiceBuilder::new().layer(ValidateRequestHeaderLayer::bearer(&token)); + let server = ServerBuilder::default() + .set_http_middleware(http_middleware) .build((host, port)) .await .context("Failed to build RPC server")?; @@ -49,7 +61,11 @@ impl RpcServer { }; let handle = server.start(rpc_impl.into_rpc()); - tracing::info!("JSON-RPC server listening on {}", addr); + tracing::info!( + "JSON-RPC server listening on {}, auth token written to {}", + addr, + cookie_path.display() + ); Ok(Self { handle }) } @@ -62,6 +78,20 @@ impl RpcServer { } } +fn write_cookie(path: &Path, token: &str) -> Result<()> { + std::fs::write(path, token) + .with_context(|| format!("Failed to write RPC cookie file to {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .context("Failed to restrict permissions on RPC cookie file")?; + } + + Ok(()) +} + pub struct RpcImpl { bitcoin_wallet: Arc, monero_wallet: Arc, diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 7ac1d517c1..038b8b3feb 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -208,7 +208,22 @@ ask_spread = "0" ) .await; + let alice_cookie_path = alice_db_path + .parent() + .expect("db_path has a parent directory") + .join(swap_controller_api::RPC_COOKIE_FILE_NAME); + let alice_rpc_token = std::fs::read_to_string(&alice_cookie_path) + .expect("Failed to read RPC cookie file") + .trim() + .to_string(); + let mut alice_rpc_headers = jsonrpsee::http_client::HeaderMap::new(); + alice_rpc_headers.insert( + "authorization", + jsonrpsee::http_client::HeaderValue::from_str(&format!("Bearer {alice_rpc_token}")) + .expect("RPC cookie token is a valid header value"), + ); let alice_rpc_client = jsonrpsee::http_client::HttpClientBuilder::default() + .set_headers(alice_rpc_headers) .build(format!("http://127.0.0.1:{}", alice_rpc_port)) .expect("Failed to create RPC client"); @@ -442,9 +457,11 @@ async fn start_alice( ) .unwrap(); + let data_dir = db_path.parent().expect("db_path has a parent directory"); let rpc_server_handle = asb::rpc::RpcServer::start( "127.0.0.1".to_string(), rpc_port, + data_dir, bitcoin_wallet, monero_wallet, service, From 9fe037c88e272b2b0e5bc87367432acc15552ff3 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Wed, 10 Jun 2026 10:40:05 +0200 Subject: [PATCH 2/3] feat(asb): persist the RPC cookie token across restarts Reuse the existing .cookie token when present so clients keep working across ASB restarts; only generate a new token when the file is missing or empty, and propagate genuine read errors. --- swap/src/asb/rpc/server.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index b55d5436ca..0344cddff6 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -38,9 +38,8 @@ impl RpcServer { event_loop_service: EventLoopService, db: Arc, ) -> Result { - let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); let cookie_path = data_dir.join(swap_controller_api::RPC_COOKIE_FILE_NAME); - write_cookie(&cookie_path, &token)?; + let token = read_or_create_cookie(&cookie_path)?; let http_middleware = tower::ServiceBuilder::new().layer(ValidateRequestHeaderLayer::bearer(&token)); @@ -62,7 +61,7 @@ impl RpcServer { let handle = server.start(rpc_impl.into_rpc()); tracing::info!( - "JSON-RPC server listening on {}, auth token written to {}", + "JSON-RPC server listening on {}, auth token at {}", addr, cookie_path.display() ); @@ -78,6 +77,22 @@ impl RpcServer { } } +fn read_or_create_cookie(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(token) if !token.trim().is_empty() => return Ok(token.trim().to_string()), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to read RPC cookie file at {}", path.display())); + } + } + + let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + write_cookie(path, &token)?; + Ok(token) +} + fn write_cookie(path: &Path, token: &str) -> Result<()> { std::fs::write(path, token) .with_context(|| format!("Failed to write RPC cookie file to {}", path.display()))?; From ad10fbe638ce7be7175d1159706f84d074d1b65a Mon Sep 17 00:00:00 2001 From: binarybaron Date: Wed, 10 Jun 2026 12:34:42 +0200 Subject: [PATCH 3/3] feat(asb): hashed-password auth for the RPC server Replace the cookie-file scheme with a hashed password: the ASB verifies the presented Bearer password against a salt:hmac HMAC-SHA256 verifier read from --rpc-auth-file (mandatory when the RPC server is enabled). asb-controller prompts for the password on startup and verifies before granting access. The orchestrator gains a gen-rpc-auth command to produce the keyfile and mounts it into the asb container. --- .gitignore | 3 + CHANGELOG.md | 2 + Cargo.lock | 6 +- justfile | 6 +- swap-asb/src/command.rs | 11 ++++ swap-asb/src/main.rs | 19 +++++- swap-controller-api/src/lib.rs | 2 - swap-controller/Cargo.toml | 1 + swap-controller/src/cli.rs | 7 -- swap-controller/src/main.rs | 71 +++++++++++++-------- swap-env/Cargo.toml | 4 ++ swap-env/src/lib.rs | 1 + swap-env/src/rpc_auth.rs | 106 +++++++++++++++++++++++++++++++ swap-orchestrator/README.md | 16 ++++- swap-orchestrator/src/compose.rs | 28 ++++---- swap-orchestrator/src/keygen.rs | 31 +++++++++ swap-orchestrator/src/main.rs | 8 ++- swap/Cargo.toml | 4 +- swap/src/asb/rpc/server.rs | 70 +++++++++----------- swap/tests/harness/mod.rs | 18 +----- 20 files changed, 304 insertions(+), 110 deletions(-) create mode 100644 swap-env/src/rpc_auth.rs create mode 100644 swap-orchestrator/src/keygen.rs diff --git a/.gitignore b/.gitignore index cd376b2ed4..6b74addbc6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ tempdb.sqlite swap-orchestrator/docker-compose.yml swap-orchestrator/config.toml +# ASB RPC auth keyfile (secret salt:hmac verifier) +rpc-auth + # release build generator scripts release-build.sh cn_macos diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b6c79e70..2c0200a845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- ASB+CONTROLLER: The JSON-RPC server now requires authentication. The ASB verifies a password against a hashed `salt:hmac` keyfile (`--rpc-auth-file`), and `asb-controller` prompts for the password on startup. Generate the keyfile with `orchestrator gen-rpc-auth`. + ## [4.8.4] - 2026-06-09 ## [4.8.3] - 2026-06-08 diff --git a/Cargo.lock b/Cargo.lock index b89c441b00..15a1ad3ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10721,6 +10721,7 @@ dependencies = [ "bitcoin 0.32.8", "clap 4.6.0", "comfy-table", + "dialoguer", "jsonrpsee", "monero-oxide-ext", "rustyline", @@ -10796,10 +10797,14 @@ dependencies = [ "config", "console 0.16.3", "dialoguer", + "hex", + "hmac", "libp2p", "monero-address", + "rand 0.8.5", "rust_decimal", "serde", + "sha2", "swap-fs", "swap-serde", "thiserror 1.0.69", @@ -13094,7 +13099,6 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "base64 0.22.1", "bitflags 2.11.0", "bytes", "futures-util", diff --git a/justfile b/justfile index 337875d749..118c36f112 100644 --- a/justfile +++ b/justfile @@ -85,9 +85,13 @@ test_monero_sys: swap: cargo build -p swap-asb --bin asb && cd swap && cargo build --bin=swap +# Generate the ASB RPC auth keyfile +gen-rpc-auth: + cargo run -p swap-orchestrator --bin orchestrator -- gen-rpc-auth + # Run the asb on testnet asb-testnet: - cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 + cargo run -p swap-asb --bin asb -- --testnet --trace start --rpc-bind-port 9944 --rpc-bind-host 0.0.0.0 --rpc-auth-file rpc-auth # Launch the ASB controller REPL against a local testnet ASB instance asb-testnet-controller: diff --git a/swap-asb/src/command.rs b/swap-asb/src/command.rs index e3999675a7..601da11220 100644 --- a/swap-asb/src/command.rs +++ b/swap-asb/src/command.rs @@ -30,6 +30,7 @@ where resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, } => { // Validate RPC bind arguments early validate_rpc_bind_args(&rpc_bind_host, &rpc_bind_port)?; @@ -44,6 +45,7 @@ where resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, }, } } @@ -226,6 +228,7 @@ pub enum Command { resume_only: bool, rpc_bind_host: Option, rpc_bind_port: Option, + rpc_auth_file: Option, }, History { only_unfinished: bool, @@ -319,6 +322,11 @@ pub enum RawCommand { help = "Port to bind the JSON-RPC server to (e.g., 9944). Must be used together with --rpc-bind-host." )] rpc_bind_port: Option, + #[structopt( + long = "rpc-auth-file", + help = "Path to a file containing the `salt:hmac` RPC auth verifier. Required when the JSON-RPC server is enabled." + )] + rpc_auth_file: Option, }, #[structopt(about = "Prints all logging messages issued in the past.")] Logs { @@ -494,6 +502,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); @@ -707,6 +716,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); @@ -948,6 +958,7 @@ mod tests { resume_only: false, rpc_bind_host: None, rpc_bind_port: None, + rpc_auth_file: None, }, }; let args = parse_args(raw_ars).unwrap(); diff --git a/swap-asb/src/main.rs b/swap-asb/src/main.rs index 57b1489566..14277e6340 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -157,6 +157,7 @@ pub async fn main() -> Result<()> { resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, } => { let db = open_db(db_file, AccessMode::ReadWrite, None).await?; @@ -388,10 +389,26 @@ pub async fn main() -> Result<()> { // Start RPC server conditionally let _rpc_server = if let (Some(host), Some(port)) = (rpc_bind_host, rpc_bind_port) { + let auth_file = rpc_auth_file.context( + "The JSON-RPC server requires authentication: pass --rpc-auth-file pointing at a `salt:hmac` verifier file", + )?; + let auth_verifier = std::fs::read_to_string(&auth_file) + .with_context(|| { + format!("Failed to read RPC auth file at {}", auth_file.display()) + })? + .trim() + .to_string(); + if !auth_verifier.contains(':') { + anyhow::bail!( + "RPC auth file at {} is malformed: expected `salt:hmac`", + auth_file.display() + ); + } + let rpc_server = RpcServer::start( host, port, - &config.data.dir, + Some(auth_verifier), bitcoin_wallet.clone(), monero_wallet.clone(), event_loop_service, diff --git a/swap-controller-api/src/lib.rs b/swap-controller-api/src/lib.rs index c5d8bd9ecd..e3a61f7f9a 100644 --- a/swap-controller-api/src/lib.rs +++ b/swap-controller-api/src/lib.rs @@ -3,8 +3,6 @@ use jsonrpsee::types::ErrorObjectOwned; use serde::{Deserialize, Serialize}; use uuid::Uuid; -pub const RPC_COOKIE_FILE_NAME: &str = ".cookie"; - #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BitcoinBalanceResponse { #[serde(with = "bitcoin::amount::serde::as_sat")] diff --git a/swap-controller/Cargo.toml b/swap-controller/Cargo.toml index 1009f99935..dde35d1553 100644 --- a/swap-controller/Cargo.toml +++ b/swap-controller/Cargo.toml @@ -12,6 +12,7 @@ anyhow = { workspace = true } bitcoin = { workspace = true } clap = { version = "4", features = ["derive"] } comfy-table = "7.2.1" +dialoguer = { workspace = true } jsonrpsee = { workspace = true, features = ["client-core", "http-client"] } monero-oxide-ext = { path = "../monero-oxide-ext" } rustyline = "17.0.0" diff --git a/swap-controller/src/cli.rs b/swap-controller/src/cli.rs index 1c2b5d0bff..2f7caa9c17 100644 --- a/swap-controller/src/cli.rs +++ b/swap-controller/src/cli.rs @@ -1,5 +1,4 @@ use clap::{Parser, Subcommand}; -use std::path::PathBuf; use uuid::Uuid; #[derive(Parser)] @@ -10,10 +9,6 @@ pub struct Cli { #[arg(long, default_value = "http://127.0.0.1:9944")] pub url: String, - /// Path to the RPC auth cookie file written by the ASB - #[arg(long)] - pub cookie: PathBuf, - /// Command to execute (defaults to interactive shell if omitted) #[command(subcommand)] pub cmd: Option, @@ -21,8 +16,6 @@ pub struct Cli { #[derive(Subcommand, Clone)] pub enum Cmd { - /// Print the RPC auth cookie token - Cookie, /// Check connection to ASB server CheckConnection, /// Get Bitcoin balance diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 7b01537fac..38fb4a65d3 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -4,37 +4,19 @@ mod repl; use anyhow::Context; use clap::Parser; use cli::{Cli, Cmd}; -use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClientBuilder}; +use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClient, HttpClientBuilder}; use swap_controller_api::{AsbApiClient, MoneroSeedResponse}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let token = std::fs::read_to_string(&cli.cookie) - .with_context(|| format!("Failed to read RPC cookie file at {}", cli.cookie.display()))?; - let token = token.trim().to_string(); - - let mut headers = HeaderMap::new(); - headers.insert( - "authorization", - HeaderValue::from_str(&format!("Bearer {token}")) - .context("Cookie token is not a valid HTTP header value")?, - ); - let client = HttpClientBuilder::default() - .set_headers(headers) - .build(&cli.url)?; + let client = authenticate(&cli.url).await?; match cli.cmd { - None => { - repl::run(client, move |cmd, client| { - let token = token.clone(); - async move { dispatch(cmd, client, token).await } - }) - .await? - } + None => repl::run(client, dispatch).await?, Some(cmd) => { - if let Err(e) = dispatch(cmd.clone(), client.clone(), token).await { + if let Err(e) = dispatch(cmd.clone(), client.clone()).await { eprintln!("Command failed with error: {e:?}"); } } @@ -43,11 +25,48 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -async fn dispatch(cmd: Cmd, client: impl AsbApiClient, token: String) -> anyhow::Result<()> { - match cmd { - Cmd::Cookie => { - println!("{token}"); +/// Prompts for the RPC password and returns a client once the server accepts +/// it, re-prompting on an authentication failure and bailing if the server is +/// unreachable for any other reason. +async fn authenticate(url: &str) -> anyhow::Result { + loop { + let password = dialoguer::Password::new() + .with_prompt("ASB RPC password") + .interact() + .context("Failed to read password")?; + + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {password}")) + .context("Password is not a valid HTTP header value")?, + ); + let client = HttpClientBuilder::default() + .set_headers(headers) + .build(url)?; + + match client.check_connection().await { + Ok(()) => return Ok(client), + Err(e) if is_auth_failure(&e) => eprintln!("Authentication failed, try again."), + Err(e) => return Err(e).context("Failed to reach the ASB RPC server"), } + } +} + +fn is_auth_failure(error: &jsonrpsee::core::ClientError) -> bool { + use jsonrpsee::http_client::transport::Error as TransportError; + + let jsonrpsee::core::ClientError::Transport(source) = error else { + return false; + }; + matches!( + source.downcast_ref::(), + Some(TransportError::Rejected { status_code: 401 }) + ) +} + +async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { + match cmd { Cmd::CheckConnection => { client.check_connection().await?; println!("Connected"); diff --git a/swap-env/Cargo.toml b/swap-env/Cargo.toml index cbaa67850d..084176a989 100644 --- a/swap-env/Cargo.toml +++ b/swap-env/Cargo.toml @@ -9,10 +9,14 @@ bitcoin = { workspace = true } config = { version = "0.14", default-features = false, features = ["toml"] } console = { workspace = true } dialoguer = { workspace = true } +hex = "0.4" +hmac = "0.12" libp2p = { workspace = true, features = ["serde"] } monero-address = { workspace = true } +rand = { workspace = true } rust_decimal = { workspace = true } serde = { workspace = true } +sha2 = { workspace = true } swap-fs = { path = "../swap-fs" } swap-serde = { path = "../swap-serde" } thiserror = { workspace = true } diff --git a/swap-env/src/lib.rs b/swap-env/src/lib.rs index fa2447bccb..57b85d6208 100644 --- a/swap-env/src/lib.rs +++ b/swap-env/src/lib.rs @@ -2,3 +2,4 @@ pub mod config; pub mod defaults; pub mod env; pub mod prompt; +pub mod rpc_auth; diff --git a/swap-env/src/rpc_auth.rs b/swap-env/src/rpc_auth.rs new file mode 100644 index 0000000000..ec9bb21352 --- /dev/null +++ b/swap-env/src/rpc_auth.rs @@ -0,0 +1,106 @@ +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +const SALT_BYTES: usize = 16; +const MIN_PASSWORD_LENGTH: usize = 16; + +/// Builds a `:` verifier for the password using a fresh random salt. +pub fn generate(password: &str) -> String { + let mut salt = [0u8; SALT_BYTES]; + rand::thread_rng().fill_bytes(&mut salt); + let salt = hex::encode(salt); + let hmac = hash_with_salt(password, &salt); + format!("{salt}:{hmac}") +} + +/// Constant-time check of a password against a `:` verifier. +/// A malformed verifier never authenticates. +pub fn verify(password: &str, verifier: &str) -> bool { + let Some((salt, expected)) = verifier.split_once(':') else { + return false; + }; + let Ok(expected) = hex::decode(expected) else { + return false; + }; + + let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length"); + mac.update(password.as_bytes()); + mac.verify_slice(&expected).is_ok() +} + +fn hash_with_salt(password: &str, salt: &str) -> String { + let mut mac = HmacSha256::new_from_slice(salt.as_bytes()).expect("HMAC accepts any key length"); + mac.update(password.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Rejects weak passwords. Requires length and a mix of character classes, +/// and forbids whitespace to keep the value unambiguous in an HTTP header. +pub fn validate_password_strength(password: &str) -> Result<(), String> { + let mut missing = Vec::new(); + + if password.chars().count() < MIN_PASSWORD_LENGTH { + missing.push(format!("at least {MIN_PASSWORD_LENGTH} characters")); + } + if !password.chars().any(|c| c.is_ascii_lowercase()) { + missing.push("a lowercase letter".to_string()); + } + if !password.chars().any(|c| c.is_ascii_uppercase()) { + missing.push("an uppercase letter".to_string()); + } + if !password.chars().any(|c| c.is_ascii_digit()) { + missing.push("a digit".to_string()); + } + if !password + .chars() + .any(|c| !c.is_ascii_alphanumeric() && !c.is_whitespace()) + { + missing.push("a special character".to_string()); + } + + if password.chars().any(char::is_whitespace) { + return Err("Password must not contain whitespace".to_string()); + } + if missing.is_empty() { + return Ok(()); + } + + Err(format!("Password is too weak; it must have {}", missing.join(", "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_then_verify_roundtrips() { + let verifier = generate("Str0ng!Passphrase42xx"); + assert!(verify("Str0ng!Passphrase42xx", &verifier)); + assert!(!verify("not the password", &verifier)); + } + + #[test] + fn verify_is_reproducible_for_a_fixed_salt() { + let verifier = format!("deadbeef:{}", hash_with_salt("hunter2", "deadbeef")); + assert!(verify("hunter2", &verifier)); + assert!(!verify("hunter3", &verifier)); + } + + #[test] + fn malformed_verifiers_never_authenticate() { + assert!(!verify("pw", "missing-colon")); + assert!(!verify("pw", "salt:not-hex")); + assert!(!verify("pw", "")); + } + + #[test] + fn strength_rejects_weak_and_accepts_strong() { + assert!(validate_password_strength("Sh0rt!").is_err()); + assert!(validate_password_strength("alllowercaseletters").is_err()); + assert!(validate_password_strength("has a space In It 9!").is_err()); + assert!(validate_password_strength("Str0ng!Passphrase42xx").is_ok()); + } +} diff --git a/swap-orchestrator/README.md b/swap-orchestrator/README.md index 7d48d3862f..87c0c088a7 100644 --- a/swap-orchestrator/README.md +++ b/swap-orchestrator/README.md @@ -56,6 +56,18 @@ To build the images, run this command. Also run this after upgrading the `orches docker compose build --no-cache # --no-cache fixes a git caching issue (error: tag clobbered) ``` +### Set the JSON-RPC password + +The `asb` authenticates its JSON-RPC endpoint, and refuses to start it without an auth keyfile. Before starting the environment, generate one: + +```bash +./orchestrator gen-rpc-auth +``` + +This prompts for a strong password, writes its salted HMAC-SHA256 verifier to `./rpc-auth` (mounted read-only into the `asb` container), and never stores the password itself. Keep the password — you enter it when attaching to `asb-controller`. + +To expose the RPC endpoint publicly (e.g. for a remote `asb-controller`), add an ingress rule to your existing Cloudflare tunnel pointing a hostname — on any Cloudflare-managed domain — at `http://asb:9944`. The password protects it, so no separate tunnel is required. + To start the environment, run a command [such as](https://docs.docker.com/reference/cli/docker/compose/up/): ```bash @@ -77,11 +89,13 @@ To view high-verbosity logs of the asb, peek inside the `asb-tracing-logger` con docker compose logs -f --tail 100 asb-tracing-logger ``` -Once the `asb` is running properly you can get a shell +Once the `asb` is running properly you can get a shell. It prompts for the RPC password you set with `gen-rpc-auth` before granting access. ```bash $ docker compose attach asb-controller +ASB RPC password: ******** + ASB Control Shell - Type 'help' for commands, 'quit' to exit asb> help diff --git a/swap-orchestrator/src/compose.rs b/swap-orchestrator/src/compose.rs index 0417a97964..f1a4ba6766 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -15,7 +15,8 @@ pub const DOCKER_LOG_MAX_FILE: &str = "5"; pub const ASB_DATA_DIR: &str = "/asb-data"; pub const ASB_CONFIG_FILE: &str = "config.toml"; -pub const ASB_RPC_COOKIE_FILE: &str = ".cookie"; +pub const ASB_RPC_AUTH_FILE: &str = "rpc-auth"; +pub const ASB_RPC_AUTH_FILE_ON_HOST: &str = "./rpc-auth"; pub const DOCKER_COMPOSE_FILE: &str = "./docker-compose.yml"; pub const PROMTAIL_CONFIG_FILE: &str = "./promtail.yml"; pub const PROMETHEUS_CONFIG_FILE: &str = "./prometheus.yml"; @@ -178,6 +179,14 @@ impl OrchestratorDirectories { pub fn asb_config_path_on_host_as_path_buf(&self) -> PathBuf { PathBuf::from(self.asb_config_path_on_host()) } + + pub fn asb_rpc_auth_path_inside_container(&self) -> PathBuf { + self.asb_data_dir.join(ASB_RPC_AUTH_FILE) + } + + pub fn asb_rpc_auth_path_on_host(&self) -> &'static str { + ASB_RPC_AUTH_FILE_ON_HOST + } } /// See: https://docs.docker.com/reference/compose-file/build/#illustrative-example @@ -235,6 +244,10 @@ fn build(input: OrchestratorInput) -> String { flag!("start"), flag!("--rpc-bind-port={}", input.ports.asb_rpc_port), flag!("--rpc-bind-host=0.0.0.0"), + flag!( + "--rpc-auth-file={}", + PathBuf::from(ASB_DATA_DIR).join(ASB_RPC_AUTH_FILE).display() + ), ]; // monerod's --proxy addr:port and --tx-proxy tor,addr;port can only take numeric addr, @@ -298,14 +311,6 @@ fn build(input: OrchestratorInput) -> String { let command_asb_controller = command![ "asb-controller", flag!("--url=http://asb:{}", input.ports.asb_rpc_port), - flag!( - "--cookie={}", - input - .directories - .asb_data_dir - .join(ASB_RPC_COOKIE_FILE) - .display() - ), ]; let command_asb_tracing_logger = command![ @@ -562,6 +567,7 @@ services: - electrs volumes: - '{asb_config_path_on_host}:{asb_config_path_inside_container}' + - '{asb_rpc_auth_path_on_host}:{asb_rpc_auth_path_inside_container}:ro' - 'asb-data:{asb_data_dir}' ports: - '0.0.0.0:{asb_port}:{asb_port}' @@ -576,8 +582,6 @@ services: logging: *default-logging depends_on: - asb - volumes: - - 'asb-data:{asb_data_dir}:ro' entrypoint: '' command: {command_asb_controller} asb-tracing-logger: @@ -631,6 +635,8 @@ volumes: asb_data_dir = input.directories.asb_data_dir.display(), asb_config_path_on_host = input.directories.asb_config_path_on_host(), asb_config_path_inside_container = input.directories.asb_config_path_inside_container().display(), + asb_rpc_auth_path_on_host = input.directories.asb_rpc_auth_path_on_host(), + asb_rpc_auth_path_inside_container = input.directories.asb_rpc_auth_path_inside_container().display(), ); validate_compose(&compose_str); diff --git a/swap-orchestrator/src/keygen.rs b/swap-orchestrator/src/keygen.rs new file mode 100644 index 0000000000..a78f402e3e --- /dev/null +++ b/swap-orchestrator/src/keygen.rs @@ -0,0 +1,31 @@ +use crate::compose::ASB_RPC_AUTH_FILE_ON_HOST; + +/// Prompts for a strong password and writes its `salt:hmac` verifier to the +/// host keyfile the generated compose mounts into the asb container. +pub fn generate_rpc_auth_keyfile() { + let password = dialoguer::Password::new() + .with_prompt("Enter a strong RPC password") + .with_confirmation("Confirm password", "Passwords do not match") + .interact() + .expect("Failed to read password"); + + if let Err(problem) = swap_env::rpc_auth::validate_password_strength(&password) { + panic!("{problem}"); + } + + let verifier = swap_env::rpc_auth::generate(&password); + std::fs::write(ASB_RPC_AUTH_FILE_ON_HOST, &verifier).expect("Failed to write RPC auth keyfile"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + ASB_RPC_AUTH_FILE_ON_HOST, + std::fs::Permissions::from_mode(0o600), + ) + .expect("Failed to restrict permissions on RPC auth keyfile"); + } + + println!("Wrote RPC auth verifier to {ASB_RPC_AUTH_FILE_ON_HOST}"); + println!("Enter this password in asb-controller to access the RPC server."); +} diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 812c27c594..c154bbf949 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -1,6 +1,7 @@ mod compose; mod containers; mod images; +mod keygen; mod prompt; use swap_orchestrator as _; @@ -174,6 +175,11 @@ fn read_gh_token_from_env() -> Option { } fn main() { + if std::env::args().nth(1).as_deref() == Some("gen-rpc-auth") { + keygen::generate_rpc_auth_keyfile(); + return; + } + // Cloudflare Tunnel is opt-in via env vars so existing deployments // keep working unchanged. let cloudflared_config = read_cloudflared_config_from_env(); @@ -316,7 +322,7 @@ fn main() { config_prompt::rendezvous_points().expect("Failed to prompt for rendezvous points"); let tor_hidden_service = config_prompt::tor_hidden_service().expect("Failed to prompt for tor hidden service"); - let listen_addresses = config_prompt::listen_addresses(&defaults.listen_address_tcp) + let listen_addresses = config_prompt::listen_addresses(&defaults.listen_addresses) .expect("Failed to prompt for listen addresses"); let monero_node_type = prompt::monero_node_type(); let electrum_server_type = prompt::electrum_server_type(&defaults.electrum_rpc_urls); diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 400ef320d8..4828e66f17 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -91,8 +91,8 @@ http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } prometheus-client = "0.22" -tower = "0.5" -tower-http = { version = "0.6", features = ["auth", "validate-request"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["validate-request"] } # Tokio tokio = { workspace = true, features = ["process", "fs", "net", "parking_lot", "rt"] } diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index 0344cddff6..7d8936c359 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -3,15 +3,13 @@ use crate::monero; use crate::protocol::Database; use anyhow::{Context, Result}; use bitcoin_wallet::BitcoinWallet; -use jsonrpsee::server::{ServerBuilder, ServerHandle}; +use jsonrpsee::server::{HttpBody, HttpRequest, HttpResponse, ServerBuilder, ServerHandle}; use jsonrpsee::types::ErrorObjectOwned; use jsonrpsee::types::error::ErrorCode; -use rand::distributions::{Alphanumeric, DistString}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::{Decimal, RoundingStrategy}; -use std::path::Path; use std::sync::Arc; -use tower_http::validate_request::ValidateRequestHeaderLayer; +use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer}; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, ExternalBitcoinRedeemAddressResponse, MoneroAddressResponse, MoneroBalanceResponse, @@ -32,17 +30,18 @@ impl RpcServer { pub async fn start( host: String, port: u16, - data_dir: &Path, + auth_verifier: Option, bitcoin_wallet: Arc, monero_wallet: Arc, event_loop_service: EventLoopService, db: Arc, ) -> Result { - let cookie_path = data_dir.join(swap_controller_api::RPC_COOKIE_FILE_NAME); - let token = read_or_create_cookie(&cookie_path)?; - - let http_middleware = - tower::ServiceBuilder::new().layer(ValidateRequestHeaderLayer::bearer(&token)); + let http_middleware = tower::ServiceBuilder::new() + .option_layer(auth_verifier.map(|verifier| { + ValidateRequestHeaderLayer::custom(BearerPasswordAuth { + verifier: Arc::from(verifier), + }) + })); let server = ServerBuilder::default() .set_http_middleware(http_middleware) @@ -60,11 +59,7 @@ impl RpcServer { }; let handle = server.start(rpc_impl.into_rpc()); - tracing::info!( - "JSON-RPC server listening on {}, auth token at {}", - addr, - cookie_path.display() - ); + tracing::info!("JSON-RPC server listening on {}", addr); Ok(Self { handle }) } @@ -77,34 +72,29 @@ impl RpcServer { } } -fn read_or_create_cookie(path: &Path) -> Result { - match std::fs::read_to_string(path) { - Ok(token) if !token.trim().is_empty() => return Ok(token.trim().to_string()), - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(e) - .with_context(|| format!("Failed to read RPC cookie file at {}", path.display())); - } - } - - let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); - write_cookie(path, &token)?; - Ok(token) +#[derive(Clone)] +struct BearerPasswordAuth { + verifier: Arc, } -fn write_cookie(path: &Path, token: &str) -> Result<()> { - std::fs::write(path, token) - .with_context(|| format!("Failed to write RPC cookie file to {}", path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .context("Failed to restrict permissions on RPC cookie file")?; +impl ValidateRequest for BearerPasswordAuth { + type ResponseBody = HttpBody; + + fn validate(&mut self, request: &mut HttpRequest) -> Result<(), HttpResponse> { + let presented = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + + match presented { + Some(password) if swap_env::rpc_auth::verify(password, &self.verifier) => Ok(()), + _ => Err(HttpResponse::builder() + .status(401) + .body(HttpBody::empty()) + .expect("static 401 response is valid")), + } } - - Ok(()) } pub struct RpcImpl { diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 038b8b3feb..9d82b50123 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -208,22 +208,7 @@ ask_spread = "0" ) .await; - let alice_cookie_path = alice_db_path - .parent() - .expect("db_path has a parent directory") - .join(swap_controller_api::RPC_COOKIE_FILE_NAME); - let alice_rpc_token = std::fs::read_to_string(&alice_cookie_path) - .expect("Failed to read RPC cookie file") - .trim() - .to_string(); - let mut alice_rpc_headers = jsonrpsee::http_client::HeaderMap::new(); - alice_rpc_headers.insert( - "authorization", - jsonrpsee::http_client::HeaderValue::from_str(&format!("Bearer {alice_rpc_token}")) - .expect("RPC cookie token is a valid header value"), - ); let alice_rpc_client = jsonrpsee::http_client::HttpClientBuilder::default() - .set_headers(alice_rpc_headers) .build(format!("http://127.0.0.1:{}", alice_rpc_port)) .expect("Failed to create RPC client"); @@ -457,11 +442,10 @@ async fn start_alice( ) .unwrap(); - let data_dir = db_path.parent().expect("db_path has a parent directory"); let rpc_server_handle = asb::rpc::RpcServer::start( "127.0.0.1".to_string(), rpc_port, - data_dir, + None, bitcoin_wallet, monero_wallet, service,