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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions swap-asb/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -44,6 +45,7 @@ where
resume_only,
rpc_bind_host,
rpc_bind_port,
rpc_auth_file,
},
}
}
Expand Down Expand Up @@ -226,6 +228,7 @@ pub enum Command {
resume_only: bool,
rpc_bind_host: Option<String>,
rpc_bind_port: Option<u16>,
rpc_auth_file: Option<PathBuf>,
},
History {
only_unfinished: bool,
Expand Down Expand Up @@ -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<u16>,
#[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<PathBuf>,
},
#[structopt(about = "Prints all logging messages issued in the past.")]
Logs {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions swap-asb/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down Expand Up @@ -388,9 +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",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

salt:hmac is too specific here

)?;
let auth_verifier = std::fs::read_to_string(&auth_file)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extact this into a function (together with the sanity check below)

.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,
Some(auth_verifier),
bitcoin_wallet.clone(),
monero_wallet.clone(),
event_loop_service,
Expand Down
1 change: 1 addition & 0 deletions swap-controller/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 43 additions & 1 deletion swap-controller/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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?,
Expand All @@ -23,6 +25,46 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}

/// 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<HttpClient> {
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::<TransportError>(),
Some(TransportError::Rejected { status_code: 401 })
)
}

async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> {
match cmd {
Cmd::CheckConnection => {
Expand Down
4 changes: 4 additions & 0 deletions swap-env/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions swap-env/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod config;
pub mod defaults;
pub mod env;
pub mod prompt;
pub mod rpc_auth;
106 changes: 106 additions & 0 deletions swap-env/src/rpc_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

const SALT_BYTES: usize = 16;
const MIN_PASSWORD_LENGTH: usize = 16;

/// Builds a `<salt>:<hmac>` 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 `<salt>:<hmac>` verifier.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont say its constant time

/// 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());
}
}
Loading
Loading