From 0bf60b803fe444b4efa6048a2dd78b16a45ae9cb Mon Sep 17 00:00:00 2001 From: binarybaron Date: Wed, 10 Jun 2026 12:43:21 +0200 Subject: [PATCH] feat(asb): authenticate the JSON-RPC server with a hashed password The ASB verifies a 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 | 8 ++ justfile | 6 +- swap-asb/src/command.rs | 11 +++ swap-asb/src/main.rs | 12 +++ swap-controller/Cargo.toml | 1 + swap-controller/src/main.rs | 66 +++++++++++++- swap-env/Cargo.toml | 4 + swap-env/src/lib.rs | 1 + swap-env/src/rpc_auth.rs | 142 +++++++++++++++++++++++++++++++ swap-orchestrator/README.md | 14 ++- swap-orchestrator/src/compose.rs | 13 +++ swap-orchestrator/src/keygen.rs | 33 +++++++ swap-orchestrator/src/main.rs | 23 +++++ swap/Cargo.toml | 2 + swap/src/asb/rpc/server.rs | 37 +++++++- swap/tests/harness/mod.rs | 1 + 18 files changed, 373 insertions(+), 6 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..ee64328e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ tempdb.sqlite swap-orchestrator/docker-compose.yml swap-orchestrator/config.toml +# ASB RPC auth keyfile +rpc-auth + # release build generator scripts release-build.sh cn_macos diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b6c79e70..853b093d24 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 keyfile (`--rpc-auth-file`), and `asb-controller` prompts for the password on startup. Generate the keyfile with `orchestrator gen-rpc-auth`. Clients authenticate by sending the password with every request in an `Authorization: Bearer ` header. + ## [4.8.4] - 2026-06-09 ## [4.8.3] - 2026-06-08 diff --git a/Cargo.lock b/Cargo.lock index d1cfdd81ee..15a1ad3ee4 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", @@ -10719,6 +10721,7 @@ dependencies = [ "bitcoin 0.32.8", "clap 4.6.0", "comfy-table", + "dialoguer", "jsonrpsee", "monero-oxide-ext", "rustyline", @@ -10794,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", @@ -13098,6 +13105,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "iri-string", + "mime", "pin-project-lite", "tower", "tower-layer", 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..91368a194e 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 the RPC auth verifier file. 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 5450533800..59025a7d0f 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -157,7 +157,18 @@ pub async fn main() -> Result<()> { resume_only, rpc_bind_host, rpc_bind_port, + rpc_auth_file, } => { + let rpc_auth_verifier = match (&rpc_bind_host, &rpc_bind_port) { + (Some(_), Some(_)) => { + let auth_file = rpc_auth_file.context( + "The JSON-RPC server requires authentication: pass --rpc-auth-file pointing at the RPC auth verifier file", + )?; + Some(swap_env::rpc_auth::load_verifier(&auth_file)?) + } + _ => None, + }; + let db = open_db(db_file, AccessMode::ReadWrite, None).await?; let developer_tip = config.maker.developer_tip; @@ -391,6 +402,7 @@ pub async fn main() -> Result<()> { let rpc_server = RpcServer::start( host, port, + rpc_auth_verifier, bitcoin_wallet.clone(), monero_wallet.clone(), event_loop_service, 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/main.rs b/swap-controller/src/main.rs index 108015a529..d197599f4f 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -1,20 +1,22 @@ mod cli; mod repl; +use anyhow::Context; use clap::Parser; use cli::{Cli, Cmd}; +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 client = jsonrpsee::http_client::HttpClientBuilder::default().build(&cli.url)?; + let client = authenticate(&cli.url).await?; match cli.cmd { - None => repl::run(client, dispatch).await?, + None => repl::run(client, dispatch_or_exit).await?, Some(cmd) => { - if let Err(e) = dispatch(cmd.clone(), client.clone()).await { + if let Err(e) = dispatch_or_exit(cmd.clone(), client.clone()).await { eprintln!("Command failed with error: {e:?}"); } } @@ -23,6 +25,64 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Exits when the ASB rejects the session's password (it changed while the +/// controller was running); re-authenticating requires a restart. +async fn dispatch_or_exit(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { + let result = dispatch(cmd, client).await; + + if let Err(e) = &result { + let rejected = e + .downcast_ref::() + .is_some_and(is_auth_failure); + if rejected { + eprintln!("The ASB rejected the password. It must have changed, exiting."); + std::process::exit(1); + } + } + + result +} + +/// 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 => { 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..60fc66bde5 --- /dev/null +++ b/swap-env/src/rpc_auth.rs @@ -0,0 +1,142 @@ +use anyhow::{Context, Result, bail}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; +use std::path::Path; + +type HmacSha256 = Hmac; + +const SALT_BYTES: usize = 16; +const DIGEST_BYTES: usize = 32; +const MIN_PASSWORD_LENGTH: usize = 16; + +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}") +} + +/// 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() +} + +pub fn load_verifier(path: &Path) -> Result { + let verifier = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read RPC auth file at {}", path.display()))? + .trim() + .to_string(); + + if !is_well_formed(&verifier) { + bail!("RPC auth file at {} is malformed", path.display()); + } + + Ok(verifier) +} + +fn is_well_formed(verifier: &str) -> bool { + verifier.split_once(':').is_some_and(|(salt, hmac)| { + !salt.is_empty() && hex::decode(hmac).is_ok_and(|digest| digest.len() == DIGEST_BYTES) + }) +} + +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. +/// Only visible ASCII is allowed so the password is always a valid HTTP +/// header value. +pub fn validate_password_strength(password: &str) -> Result<(), String> { + if !password.chars().all(|c| c.is_ascii_graphic()) { + return Err( + "Password must contain only visible ASCII characters (no whitespace, no non-ASCII symbols)" + .to_string(), + ); + } + + let mut missing = Vec::new(); + + if password.len() < 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_punctuation()) { + missing.push("a special character".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 well_formedness_requires_a_full_digest() { + assert!(is_well_formed(&generate("pw"))); + assert!(!is_well_formed("salt:")); + assert!(!is_well_formed("salt:deadbeef")); + assert!(!is_well_formed("salt:not-hex")); + assert!(!is_well_formed(":")); + assert!(!is_well_formed("missing-colon")); + let digest = "ab".repeat(DIGEST_BYTES); + assert!(!is_well_formed(&format!(":{digest}"))); + } + + #[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("Sümb0l!Passphrase42x").is_err()); + assert!(validate_password_strength("C0ntr0l!Passphrase42\u{1}").is_err()); + assert!(validate_password_strength("Str0ng!Passphrase42xx").is_ok()); + } +} diff --git a/swap-orchestrator/README.md b/swap-orchestrator/README.md index 7d48d3862f..664f56184f 100644 --- a/swap-orchestrator/README.md +++ b/swap-orchestrator/README.md @@ -56,6 +56,16 @@ 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 and writes a hashed verifier to `./rpc-auth` (mounted read-only into the `asb` container); the password itself is never stored. Keep the password — you enter it when attaching to `asb-controller`. + To start the environment, run a command [such as](https://docs.docker.com/reference/cli/docker/compose/up/): ```bash @@ -77,11 +87,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 ca36de4e8c..f1055ca61d 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -15,6 +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_AUTH_FILE_ON_HOST: &str = "./rpc-auth"; +pub const ASB_RPC_AUTH_FILE_IN_CONTAINER: &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"; @@ -177,6 +179,7 @@ impl OrchestratorDirectories { pub fn asb_config_path_on_host_as_path_buf(&self) -> PathBuf { PathBuf::from(self.asb_config_path_on_host()) } + } /// See: https://docs.docker.com/reference/compose-file/build/#illustrative-example @@ -234,6 +237,7 @@ 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={}", ASB_RPC_AUTH_FILE_IN_CONTAINER), ]; // monerod's --proxy addr:port and --tx-proxy tor,addr;port can only take numeric addr, @@ -556,6 +560,13 @@ services: - electrs volumes: - '{asb_config_path_on_host}:{asb_config_path_inside_container}' + # makes `docker compose up` fail if the keyfile is missing + - type: bind + source: '{asb_rpc_auth_file_on_host}' + target: '{asb_rpc_auth_file_in_container}' + read_only: true + bind: + create_host_path: false - 'asb-data:{asb_data_dir}' ports: - '0.0.0.0:{asb_port}:{asb_port}' @@ -623,6 +634,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_file_on_host = ASB_RPC_AUTH_FILE_ON_HOST, + asb_rpc_auth_file_in_container = ASB_RPC_AUTH_FILE_IN_CONTAINER, ); validate_compose(&compose_str); diff --git a/swap-orchestrator/src/keygen.rs b/swap-orchestrator/src/keygen.rs new file mode 100644 index 0000000000..9c4375d032 --- /dev/null +++ b/swap-orchestrator/src/keygen.rs @@ -0,0 +1,33 @@ +use crate::compose::ASB_RPC_AUTH_FILE_ON_HOST; +use std::io::Write; + +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); + + let _ = std::fs::remove_file(ASB_RPC_AUTH_FILE_ON_HOST); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(ASB_RPC_AUTH_FILE_ON_HOST) + .and_then(|mut file| file.write_all(verifier.as_bytes())) + .expect("Failed to write 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..674fad7a78 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(); @@ -437,6 +443,23 @@ fn main() { std::fs::write(DOCKER_COMPOSE_FILE, compose).expect("Failed to write docker-compose.yml"); println!(); + if !std::path::Path::new(compose::ASB_RPC_AUTH_FILE_ON_HOST).exists() { + let generate_now = dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(format!( + "No RPC auth keyfile found at {}. Without it the asb will refuse to start its RPC server. Generate one now?", + compose::ASB_RPC_AUTH_FILE_ON_HOST + )) + .default(true) + .interact() + .expect("Failed to read confirmation"); + + if generate_now { + keygen::generate_rpc_auth_keyfile(); + } else { + println!("Run `orchestrator gen-rpc-auth` before starting the services."); + } + println!(); + } println!("Run `docker compose up -d` to start the services."); if let Some(cf) = cloudflared_config.as_ref() { diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 6460241288..4828e66f17 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 = { 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 7ed89e10cc..7d8936c359 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -3,12 +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 rust_decimal::prelude::ToPrimitive; use rust_decimal::{Decimal, RoundingStrategy}; use std::sync::Arc; +use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer}; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, ExternalBitcoinRedeemAddressResponse, MoneroAddressResponse, MoneroBalanceResponse, @@ -29,12 +30,21 @@ impl RpcServer { pub async fn start( host: String, port: u16, + auth_verifier: Option, bitcoin_wallet: Arc, monero_wallet: Arc, event_loop_service: EventLoopService, db: Arc, ) -> Result { + 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) .build((host, port)) .await .context("Failed to build RPC server")?; @@ -62,6 +72,31 @@ impl RpcServer { } } +#[derive(Clone)] +struct BearerPasswordAuth { + verifier: Arc, +} + +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")), + } + } +} + 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..9d82b50123 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -445,6 +445,7 @@ async fn start_alice( let rpc_server_handle = asb::rpc::RpcServer::start( "127.0.0.1".to_string(), rpc_port, + None, bitcoin_wallet, monero_wallet, service,