From 8180472303bc2847981072e040465d6dceab8ef9 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Mon, 1 Jun 2026 00:51:34 +0100 Subject: [PATCH 1/9] feat(rpc): generate cookie file at startup Write /.cookie on startup as a single line __cookie__:<64-char-hex-token>, no trailing newline. Token is 32 bytes from rand::rng(), lowercase hex-encoded. File mode 0600 on Unix via OpenOptionsExt; atomic publish via tmp + rename. Refs: #651 --- Cargo.lock | 1 + crates/floresta-node/Cargo.toml | 1 + crates/floresta-node/src/florestad.rs | 4 + crates/floresta-node/src/json_rpc/auth.rs | 209 ++++++++++++++++++++++ crates/floresta-node/src/json_rpc/mod.rs | 1 + 5 files changed, 216 insertions(+) create mode 100644 crates/floresta-node/src/json_rpc/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 16b20f7ab..5fb14881b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1162,6 +1162,7 @@ dependencies = [ "metrics", "miniscript", "pretty_assertions", + "rand 0.9.4", "rcgen", "rustreexo", "serde", diff --git a/crates/floresta-node/Cargo.toml b/crates/floresta-node/Cargo.toml index bbdb19b16..44f5c3251 100644 --- a/crates/floresta-node/Cargo.toml +++ b/crates/floresta-node/Cargo.toml @@ -14,6 +14,7 @@ bitcoin = { workspace = true } corepc-types = { workspace = true } dns-lookup = { workspace = true } miniscript = { workspace = true, features = ["std"] } +rand = { workspace = true } rcgen = { workspace = true } rustreexo = { workspace = true } serde = { workspace = true } diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 8d96677c9..2d8f4bfd8 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -460,6 +460,10 @@ impl Florestad { // JSON-RPC #[cfg(feature = "json-rpc")] { + let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); + json_rpc::auth::generate_cookie(&cookie_path)?; + info!("RPC cookie file written to {}", cookie_path.display()); + let server = tokio::spawn(json_rpc::server::RpcImpl::create( blockchain_state.clone(), wallet.clone(), diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs new file mode 100644 index 000000000..be86dddb8 --- /dev/null +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! JSON-RPC authentication primitives. +//! +//! Cookie auth mirrors Bitcoin Core's behavior. On startup the server writes a +//! single line of the form `__cookie__:<64-char-hex-token>` to the path the +//! caller passes to [`generate_cookie`]. florestad passes a network-suffixed +//! data directory, so the on-disk layout matches Core's `/[/].cookie` +//! convention (mainnet sits at the root, every other network sits one level +//! deeper). +//! +//! Clients then send `Authorization: Basic )>`. +//! +//! The token rotates every restart; any pre-existing `.cookie` is silently +//! overwritten. + +use std::ffi::OsString; +use std::fs; +use std::fs::OpenOptions; +use std::io; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use bitcoin::hex::DisplayHex; +use rand::Rng; + +/// Username literal used for cookie auth (Core convention). +pub(crate) const COOKIE_USER: &str = "__cookie__"; + +/// Default cookie file name; placed under the net-specific datadir. +pub(crate) const COOKIE_FILE_NAME: &str = ".cookie"; + +/// Token length in raw random bytes; hex-encoded to 64 ASCII chars. +const COOKIE_TOKEN_BYTES: usize = 32; + +/// Generate a fresh cookie and write it to `path` with no trailing newline. +/// +/// Writes go to `.tmp` first with mode `0600` on Unix, then atomically +/// rename over `path`. A pre-existing cookie file is silently overwritten. +pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> { + let mut token = [0u8; COOKIE_TOKEN_BYTES]; + rand::rng().fill(&mut token); + let auth = format!("{COOKIE_USER}:{}", token.to_lower_hex_string()); + + let tmp = tmp_path(path); + + let mut opts = OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + let mut file = opts.open(&tmp)?; + file.write_all(auth.as_bytes())?; + drop(file); + + fs::rename(&tmp, path)?; + + Ok(()) +} + +fn tmp_path(path: &Path) -> PathBuf { + let mut buf = OsString::from(path); + buf.push(".tmp"); + PathBuf::from(buf) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn tmp_cookie_path(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "floresta_cookie_test_{name}_{}", + rand::random::() + )); + p + } + + #[test] + fn generate_cookie_writes_expected_format() { + let path = tmp_cookie_path("format"); + generate_cookie(&path).expect("cookie write should succeed in test"); + + let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); + assert!( + written.starts_with("__cookie__:"), + "cookie file missing prefix: {written}" + ); + let token = written + .strip_prefix("__cookie__:") + .expect("generated cookie always begins with __cookie__:"); + assert_eq!( + token.len(), + 64, + "token should be 64 hex chars, got {}", + token.len() + ); + assert!( + token + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "token should be lowercase hex: {token}", + ); + + fs::remove_file(&path).ok(); + } + + #[test] + fn generate_cookie_writes_no_trailing_newline() { + let path = tmp_cookie_path("newline"); + generate_cookie(&path).expect("cookie write should succeed in test"); + + let bytes = fs::read(&path).expect("test wrote this cookie file above"); + assert!(!bytes.ends_with(b"\n"), "file should not end with newline"); + + fs::remove_file(&path).ok(); + } + + #[test] + fn generate_cookie_produces_distinct_tokens() { + let path1 = tmp_cookie_path("distinct1"); + let path2 = tmp_cookie_path("distinct2"); + generate_cookie(&path1).expect("cookie write should succeed in test"); + generate_cookie(&path2).expect("cookie write should succeed in test"); + let auth1 = fs::read_to_string(&path1).expect("test wrote path1 above"); + let auth2 = fs::read_to_string(&path2).expect("test wrote path2 above"); + assert_ne!( + auth1, auth2, + "two consecutive calls produced identical tokens" + ); + + fs::remove_file(&path1).ok(); + fs::remove_file(&path2).ok(); + } + + #[test] + fn generate_cookie_overwrites_existing_file() { + let path = tmp_cookie_path("overwrite"); + fs::write(&path, "stale-content").expect("test temp path should be writable"); + generate_cookie(&path).expect("cookie write should succeed in test"); + let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); + assert_ne!(written, "stale-content", "stale content was not replaced"); + assert!( + written.starts_with("__cookie__:"), + "replacement is not a cookie line: {written}" + ); + + fs::remove_file(&path).ok(); + } + + #[test] + fn generate_cookie_leaves_no_tmp_file() { + let path = tmp_cookie_path("notmp"); + generate_cookie(&path).expect("cookie write should succeed in test"); + let tmp = tmp_path(&path); + assert!( + !tmp.exists(), + "tmp file should be renamed away, got {tmp:?}" + ); + + fs::remove_file(&path).ok(); + } + + #[test] + fn generate_cookie_recovers_from_stale_tmp_file() { + let path = tmp_cookie_path("stale_tmp"); + let tmp = tmp_path(&path); + + // Simulate a previous run that crashed between create and rename: + // a stale .tmp file lingers with arbitrary content. + fs::write(&tmp, b"partial-from-crashed-run").expect("test temp path should be writable"); + assert!(tmp.exists(), "precondition: stale tmp must exist"); + + generate_cookie(&path).expect("cookie write should succeed in test"); + + let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); + assert!( + written.starts_with("__cookie__:"), + "cookie file should hold a fresh cookie, got {written}" + ); + assert!(!tmp.exists(), "stale tmp file should be consumed by rename"); + + fs::remove_file(&path).ok(); + } + + #[cfg(unix)] + #[test] + fn generate_cookie_sets_owner_only_mode_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let path = tmp_cookie_path("perms"); + generate_cookie(&path).expect("cookie write should succeed in test"); + let mode = fs::metadata(&path) + .expect("test wrote this cookie file above") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "expected 0600, got {mode:o}"); + + fs::remove_file(&path).ok(); + } +} diff --git a/crates/floresta-node/src/json_rpc/mod.rs b/crates/floresta-node/src/json_rpc/mod.rs index f04e366bf..428821720 100644 --- a/crates/floresta-node/src/json_rpc/mod.rs +++ b/crates/floresta-node/src/json_rpc/mod.rs @@ -9,6 +9,7 @@ //! Version acceptance is validated in [`server`] and covered by integration //! tests in `tests/florestad/rpcserver_request_parsing.py`. +pub mod auth; pub mod request; pub mod res; pub mod server; From e3400a7a6da611bcbff460dd56b35c662f6e23a0 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Mon, 1 Jun 2026 02:38:03 +0100 Subject: [PATCH 2/9] feat(rpc): clean up cookie file on shutdown Track cookie ownership via a cookie_generated OnceLock on Florestad: set after generate_cookie succeeds at startup, checked at wait_shutdown so we only remove a cookie this process wrote. delete_cookie treats NotFound as success, so shutdown stays idempotent across crashes and double-stops. Refs: #651 --- crates/floresta-node/src/florestad.rs | 14 +++++++++++ crates/floresta-node/src/json_rpc/auth.rs | 30 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 2d8f4bfd8..57112b52f 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -273,6 +273,11 @@ pub struct Florestad { #[cfg(feature = "json-rpc")] /// A handle to our json-rpc server json_rpc: OnceLock>, + + #[cfg(feature = "json-rpc")] + /// Set once after this process writes the RPC cookie file. Gates cookie + /// deletion at shutdown so we never remove a cookie written by a prior run. + cookie_generated: OnceLock<()>, } impl Florestad { @@ -304,6 +309,12 @@ impl Florestad { if let Some(chan) = chan { try_and_log!(chan.await); } + + #[cfg(feature = "json-rpc")] + if self.cookie_generated.get().is_some() { + let cookie_path = self.config.datadir.join(json_rpc::auth::COOKIE_FILE_NAME); + try_and_log!(json_rpc::auth::delete_cookie(&cookie_path)); + } } /// Parses an address in the format `[<:port>]` and returns a @@ -462,6 +473,7 @@ impl Florestad { { let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); json_rpc::auth::generate_cookie(&cookie_path)?; + let _ = self.cookie_generated.set(()); info!("RPC cookie file written to {}", cookie_path.display()); let server = tokio::spawn(json_rpc::server::RpcImpl::create( @@ -902,6 +914,8 @@ impl From for Florestad { stop_notify: Arc::new(Mutex::new(None)), #[cfg(feature = "json-rpc")] json_rpc: OnceLock::new(), + #[cfg(feature = "json-rpc")] + cookie_generated: OnceLock::new(), } } } diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index be86dddb8..d8770b6eb 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -62,6 +62,17 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> { Ok(()) } +/// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown +/// is idempotent. Caller must only invoke this after a successful +/// [`generate_cookie`] in this process. +pub(crate) fn delete_cookie(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + fn tmp_path(path: &Path) -> PathBuf { let mut buf = OsString::from(path); buf.push(".tmp"); @@ -190,6 +201,25 @@ mod tests { fs::remove_file(&path).ok(); } + #[test] + fn delete_cookie_removes_existing_file() { + let path = tmp_cookie_path("delete_existing"); + generate_cookie(&path).expect("cookie write should succeed in test"); + assert!(path.exists()); + + delete_cookie(&path).expect("delete should succeed for an existing file"); + assert!(!path.exists()); + } + + #[test] + fn delete_cookie_is_idempotent_on_missing_file() { + let path = tmp_cookie_path("delete_missing"); + assert!(!path.exists()); + + delete_cookie(&path).expect("delete is idempotent on missing file"); + delete_cookie(&path).expect("delete is idempotent on missing file"); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { From b3fa36dc9ddb45604935c36c9811bff13a4f66a1 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Mon, 1 Jun 2026 23:41:02 +0100 Subject: [PATCH 3/9] feat(rpc): parse Authorization: Basic header Add parse_basic_auth_header and BasicAuthHeaderError. The parser matches Bitcoin Core's wire semantics: case-sensitive "Basic " prefix, whitespace-trim around the base64 payload, and split on the first ':' so passwords may contain colons. An axum middleware reads the Authorization header on each request, parses it, and logs the parsed username at debug level. Missing, non-ASCII, or malformed headers are also logged at debug. The middleware always passes the request through; this layer does not reject anything. Refs: #651 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/floresta-node/Cargo.toml | 3 +- crates/floresta-node/src/json_rpc/auth.rs | 153 ++++++++++++++++++++ crates/floresta-node/src/json_rpc/server.rs | 1 + 5 files changed, 158 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5fb14881b..0cb7cc3de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ name = "floresta-node" version = "0.9.0" dependencies = [ "axum", + "base64 0.22.1", "bitcoin", "corepc-types", "dns-lookup", diff --git a/Cargo.toml b/Cargo.toml index c7f3cbbb2..5b4103bc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ readme = "README.md" [workspace.dependencies] # All dependencies MUST be pinned precisely with `X.Y.z` axum = { version = "=0.8.9", default-features = false, features = ["http1", "json", "tracing", "tokio"] } +base64 = { version = "=0.22.1", default-features = false, features = ["std"] } bitcoin = { version = "=0.32.8", default-features = false } clap = { version = "=4.6.1", default-features = false, features = ["derive", "std"] } corepc-types = "=0.11.0" diff --git a/crates/floresta-node/Cargo.toml b/crates/floresta-node/Cargo.toml index 44f5c3251..20f42c187 100644 --- a/crates/floresta-node/Cargo.toml +++ b/crates/floresta-node/Cargo.toml @@ -10,6 +10,7 @@ authors = ["Floresta Developers "] [dependencies] # All dependencies MUST be pinned precisely with `X.Y.z` axum = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } bitcoin = { workspace = true } corepc-types = { workspace = true } dns-lookup = { workspace = true } @@ -49,7 +50,7 @@ libc = "=0.2.186" [features] compact-filters = ["dep:floresta-compact-filters"] zmq-server = ["dep:zmq"] -json-rpc = ["dep:axum", "dep:tower-http", "compact-filters"] +json-rpc = ["dep:axum", "dep:base64", "dep:tower-http", "compact-filters"] default = ["json-rpc"] metrics = ["dep:metrics", "floresta-wire/metrics", "floresta-chain/metrics"] diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index d8770b6eb..a1639ded0 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -15,6 +15,7 @@ //! overwritten. use std::ffi::OsString; +use std::fmt; use std::fs; use std::fs::OpenOptions; use std::io; @@ -22,6 +23,8 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use bitcoin::hex::DisplayHex; use rand::Rng; @@ -34,6 +37,9 @@ pub(crate) const COOKIE_FILE_NAME: &str = ".cookie"; /// Token length in raw random bytes; hex-encoded to 64 ASCII chars. const COOKIE_TOKEN_BYTES: usize = 32; +/// Upper bound on the inbound `Authorization` header to cap base64 decode allocation. +const MAX_AUTH_HEADER_LEN: usize = 16 * 1024; + /// Generate a fresh cookie and write it to `path` with no trailing newline. /// /// Writes go to `.tmp` first with mode `0600` on Unix, then atomically @@ -62,6 +68,84 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> { Ok(()) } +/// Errors produced by [`parse_basic_auth_header`]. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BasicAuthHeaderError { + /// The header value did not start with the literal `"Basic "` prefix. + MissingBasicPrefix, + /// The base64 payload could not be decoded. + InvalidBase64, + /// The decoded payload was not valid UTF-8. + InvalidUtf8, + /// The decoded payload contained no `:` separator. + MissingColon, + /// The header value exceeded the inbound length cap. + PayloadTooLarge, +} + +impl fmt::Display for BasicAuthHeaderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBasicPrefix => write!(f, "Authorization header must start with 'Basic '"), + Self::InvalidBase64 => write!(f, "Authorization payload is not valid base64"), + Self::InvalidUtf8 => write!(f, "Authorization payload is not valid UTF-8"), + Self::MissingColon => write!(f, "Authorization payload missing ':' separator"), + Self::PayloadTooLarge => write!( + f, + "Authorization header exceeds {MAX_AUTH_HEADER_LEN}-byte cap" + ), + } + } +} + +impl std::error::Error for BasicAuthHeaderError {} + +/// Axum middleware that parses an inbound `Authorization: Basic` header and +/// logs the parsed username at debug level. Requests without the header or +/// with a malformed value are passed through unchanged; this layer does not +/// reject anything. +pub(crate) async fn auth_middleware( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + if let Some(header) = req.headers().get(axum::http::header::AUTHORIZATION) { + match header.to_str() { + Ok(value) => match parse_basic_auth_header(value) { + Ok((user, _)) => tracing::debug!("rpc auth header parsed for user {user}"), + Err(e) => tracing::debug!("rpc auth header parse failed: {e}"), + }, + Err(_) => tracing::debug!("rpc auth header is not valid ascii"), + } + } + next.run(req).await +} + +/// Parse an HTTP `Authorization: Basic ` header value into `(user, pass)`. +/// +/// Mirrors Bitcoin Core: the `"Basic "` prefix check is case-sensitive, the +/// base64 payload is whitespace-trimmed before decoding, and the split between +/// user and password is on the **first** `:`. Passwords may contain `:`; +/// usernames may not. +pub(crate) fn parse_basic_auth_header( + value: &str, +) -> Result<(String, String), BasicAuthHeaderError> { + if value.len() > MAX_AUTH_HEADER_LEN { + return Err(BasicAuthHeaderError::PayloadTooLarge); + } + let payload = value + .strip_prefix("Basic ") + .ok_or(BasicAuthHeaderError::MissingBasicPrefix)? + .trim(); + let decoded = BASE64 + .decode(payload) + .map_err(|_| BasicAuthHeaderError::InvalidBase64)?; + let decoded = String::from_utf8(decoded).map_err(|_| BasicAuthHeaderError::InvalidUtf8)?; + let (user, pass) = decoded + .split_once(':') + .ok_or(BasicAuthHeaderError::MissingColon)?; + Ok((user.to_string(), pass.to_string())) +} + /// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown /// is idempotent. Caller must only invoke this after a successful /// [`generate_cookie`] in this process. @@ -220,6 +304,75 @@ mod tests { delete_cookie(&path).expect("delete is idempotent on missing file"); } + #[test] + fn parse_basic_auth_header_accepts_valid_header() { + // base64("alice:hunter2") = "YWxpY2U6aHVudGVyMg==" + let (user, pass) = parse_basic_auth_header("Basic YWxpY2U6aHVudGVyMg==") + .expect("valid Basic header should parse"); + assert_eq!(user, "alice"); + assert_eq!(pass, "hunter2"); + } + + #[test] + fn parse_basic_auth_header_trims_whitespace_around_payload() { + let (user, pass) = parse_basic_auth_header("Basic YWxpY2U6aHVudGVyMg== ") + .expect("valid Basic header with whitespace should parse"); + assert_eq!(user, "alice"); + assert_eq!(pass, "hunter2"); + } + + #[test] + fn parse_basic_auth_header_allows_colon_in_password() { + // base64("alice:pass:with:colons") = "YWxpY2U6cGFzczp3aXRoOmNvbG9ucw==" + let (user, pass) = parse_basic_auth_header("Basic YWxpY2U6cGFzczp3aXRoOmNvbG9ucw==") + .expect("valid Basic header with colons in pass should parse"); + assert_eq!(user, "alice"); + assert_eq!(pass, "pass:with:colons"); + } + + #[test] + fn parse_basic_auth_header_rejects_wrong_scheme() { + // The "Basic " prefix is case-sensitive; reject "basic ", "Bearer ...", and empty. + assert_eq!( + parse_basic_auth_header("basic YWxpY2U6aHVudGVyMg=="), + Err(BasicAuthHeaderError::MissingBasicPrefix), + ); + assert_eq!( + parse_basic_auth_header("Bearer YWxpY2U6aHVudGVyMg=="), + Err(BasicAuthHeaderError::MissingBasicPrefix), + ); + assert_eq!( + parse_basic_auth_header(""), + Err(BasicAuthHeaderError::MissingBasicPrefix), + ); + } + + #[test] + fn parse_basic_auth_header_rejects_bad_base64() { + assert_eq!( + parse_basic_auth_header("Basic !!!not-base64!!!"), + Err(BasicAuthHeaderError::InvalidBase64), + ); + } + + #[test] + fn parse_basic_auth_header_rejects_missing_colon() { + // base64("nocolon") = "bm9jb2xvbg==" + assert_eq!( + parse_basic_auth_header("Basic bm9jb2xvbg=="), + Err(BasicAuthHeaderError::MissingColon), + ); + } + + #[test] + fn parse_basic_auth_header_rejects_oversized_payload() { + let oversized = format!("Basic {}", "A".repeat(MAX_AUTH_HEADER_LEN)); + assert_eq!( + parse_basic_auth_header(&oversized), + Err(BasicAuthHeaderError::PayloadTooLarge), + ); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 96b73b530..eb01b509e 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -695,6 +695,7 @@ impl RpcImpl { .allow_private_network(true) .allow_methods([Method::POST, Method::HEAD]), ) + .layer(axum::middleware::from_fn(super::auth::auth_middleware)) .with_state(Arc::new(RpcImpl { chain, wallet, From b56f6676443278d83e09b1d610b5e557f4d90fea Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Tue, 2 Jun 2026 18:12:44 +0100 Subject: [PATCH 4/9] feat(rpc): enforce cookie authentication The JSON-RPC middleware now gates every request: missing, malformed, or non-matching Authorization: Basic headers return HTTP 401 with WWW-Authenticate: Basic realm="jsonrpc" per RFC 7235. generate_cookie returns the cookie line again so Florestad can wrap it in Arc and pass it through to the middleware state. Credentials::matches compares format!("{user}:{pass}") against the stored line via a hand-rolled constant_time_eq that runs the full length regardless of where the first mismatch lies. floresta-cli reads /[/].cookie by default; --rpc-user + --rpc-password override; --rpc-cookie-file picks a non-default path. The Python test framework reads the cookie after the RPC socket opens. Refs: #651 --- Cargo.lock | 1 + bin/floresta-cli/Cargo.toml | 1 + bin/floresta-cli/src/main.rs | 66 ++++++++- crates/floresta-node/src/florestad.rs | 4 +- crates/floresta-node/src/json_rpc/auth.rs | 155 ++++++++++++++++---- crates/floresta-node/src/json_rpc/server.rs | 6 +- crates/floresta-rpc/src/lib.rs | 31 +++- tests/test_framework/node.py | 16 ++ 8 files changed, 247 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cb7cc3de..2577fcd64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1072,6 +1072,7 @@ dependencies = [ "anyhow", "bitcoin", "clap", + "dirs", "floresta-rpc", "serde_json", ] diff --git a/bin/floresta-cli/Cargo.toml b/bin/floresta-cli/Cargo.toml index f5f5d6d69..07ce9d05b 100644 --- a/bin/floresta-cli/Cargo.toml +++ b/bin/floresta-cli/Cargo.toml @@ -21,6 +21,7 @@ categories = ["cryptography::cryptocurrencies", "command-line-utilities"] anyhow = "=1.0.102" bitcoin = { workspace = true } clap = { workspace = true, features = ["help"] } +dirs = { version = "=4.0.0", default-features = false } serde_json = { workspace = true } # Local dependencies diff --git a/bin/floresta-cli/src/main.rs b/bin/floresta-cli/src/main.rs index df785a9e3..3208e09e2 100644 --- a/bin/floresta-cli/src/main.rs +++ b/bin/floresta-cli/src/main.rs @@ -1,9 +1,12 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use core::fmt::Debug; +use std::fs; +use std::path::Path; use std::path::PathBuf; mod parsers; +use anyhow::Context; use anyhow::Ok; use bitcoin::BlockHash; use bitcoin::Network; @@ -11,6 +14,7 @@ use bitcoin::Txid; use clap::Parser; use clap::Subcommand; use floresta_rpc::jsonrpc_client::Client; +use floresta_rpc::jsonrpc_client::JsonRPCConfig; use floresta_rpc::rpc::FlorestaRPC; use floresta_rpc::rpc_types::AddNodeCommand; use floresta_rpc::rpc_types::RescanConfidence; @@ -20,8 +24,16 @@ fn main() -> anyhow::Result<()> { // Parse command line arguments into a Cli struct let cli = Cli::parse(); - // Create a new JSON-RPC client using the host from the CLI arguments - let client = Client::new(get_host(&cli)); + // Resolve basic-auth credentials: explicit flags win, otherwise read the + // cookie file written by florestad at startup. + let (user, pass) = resolve_credentials(&cli)?; + + // Create a new JSON-RPC client using the host and credentials. + let client = Client::new_with_config(JsonRPCConfig { + url: get_host(&cli), + user: Some(user), + pass: Some(pass), + }); // Perform the requested RPC call and get the result let res = do_request(&cli, client)?; @@ -33,6 +45,53 @@ fn main() -> anyhow::Result<()> { anyhow::Ok(()) } +// Resolve the basic-auth credentials for the RPC call. +// +// Precedence: +// 1. `--rpc-user` and `--rpc-password` both set: use them. +// 2. Otherwise read the cookie file at `--rpc-cookie-file` (or the network's +// default location) and split on the first `:` into (user, pass). +fn resolve_credentials(cli: &Cli) -> anyhow::Result<(String, String)> { + if let (Some(user), Some(pass)) = (cli.rpc_user.clone(), cli.rpc_password.clone()) { + return anyhow::Ok((user, pass)); + } + let cookie_path = cli + .rpc_cookie_file + .clone() + .unwrap_or_else(|| default_cookie_path(cli.network)); + read_cookie(&cookie_path) +} + +// Read a cookie file and split it into (user, pass) on the first `:`. +fn read_cookie(path: &Path) -> anyhow::Result<(String, String)> { + let contents = fs::read_to_string(path).with_context(|| { + format!( + "failed to read RPC cookie file at {}; start florestad first or pass --rpc-user/--rpc-password", + path.display() + ) + })?; + let (user, pass) = contents + .split_once(':') + .with_context(|| format!("cookie file at {} is malformed", path.display()))?; + anyhow::Ok((user.to_string(), pass.to_string())) +} + +// Compute the default cookie file path for the given network. Mirrors +// florestad's `datadir_path` layout: `~/.floresta/[/].cookie`. +fn default_cookie_path(network: Network) -> PathBuf { + let base = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".floresta"); + let net_dir = match network { + Network::Bitcoin => base, + Network::Signet => base.join("signet"), + Network::Testnet => base.join("testnet3"), + Network::Testnet4 => base.join("testnet4"), + Network::Regtest => base.join("regtest"), + }; + net_dir.join(".cookie") +} + // Function to determine the RPC host based on CLI arguments and network type fn get_host(cmd: &Cli) -> String { // If a specific RPC host is provided, use it @@ -164,6 +223,9 @@ pub struct Cli { /// The RPC password to use #[arg(short = 'P', long, value_name = "PASSWORD")] pub rpc_password: Option, + /// Path to the RPC cookie file. Defaults to `/[/].cookie`. + #[arg(long, value_name = "PATH")] + pub rpc_cookie_file: Option, /// An actual RPC command to run #[command(subcommand)] pub methods: Methods, diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 57112b52f..bb5a40bba 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -472,9 +472,10 @@ impl Florestad { #[cfg(feature = "json-rpc")] { let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); - json_rpc::auth::generate_cookie(&cookie_path)?; + let cookie = json_rpc::auth::generate_cookie(&cookie_path)?; let _ = self.cookie_generated.set(()); info!("RPC cookie file written to {}", cookie_path.display()); + let credentials = Arc::new(json_rpc::auth::Credentials::Cookie(cookie)); let server = tokio::spawn(json_rpc::server::RpcImpl::create( blockchain_state.clone(), @@ -491,6 +492,7 @@ impl Florestad { datadir.join("debug.log"), self.config.user_agent.clone(), proxy, + credentials, )); if self.json_rpc.set(server).is_err() { diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index a1639ded0..bb912db49 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -40,11 +40,12 @@ const COOKIE_TOKEN_BYTES: usize = 32; /// Upper bound on the inbound `Authorization` header to cap base64 decode allocation. const MAX_AUTH_HEADER_LEN: usize = 16 * 1024; -/// Generate a fresh cookie and write it to `path` with no trailing newline. +/// Generate a fresh cookie, write it to `path` with no trailing newline, and +/// return the `__cookie__:` line for in-process validation. /// /// Writes go to `.tmp` first with mode `0600` on Unix, then atomically /// rename over `path`. A pre-existing cookie file is silently overwritten. -pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> { +pub(crate) fn generate_cookie(path: &Path) -> io::Result { let mut token = [0u8; COOKIE_TOKEN_BYTES]; rand::rng().fill(&mut token); let auth = format!("{COOKIE_USER}:{}", token.to_lower_hex_string()); @@ -65,7 +66,7 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> { fs::rename(&tmp, path)?; - Ok(()) + Ok(auth) } /// Errors produced by [`parse_basic_auth_header`]. @@ -100,26 +101,52 @@ impl fmt::Display for BasicAuthHeaderError { impl std::error::Error for BasicAuthHeaderError {} -/// Axum middleware that parses an inbound `Authorization: Basic` header and -/// logs the parsed username at debug level. Requests without the header or -/// with a malformed value are passed through unchanged; this layer does not -/// reject anything. +/// Axum middleware that gates each request on the configured [`Credentials`]. +/// Missing, malformed, non-ASCII, or non-matching `Authorization: Basic` +/// headers all return HTTP 401 with `WWW-Authenticate: Basic realm="jsonrpc"` +/// per RFC 7235. Matching requests pass through to the handler. pub(crate) async fn auth_middleware( + axum::extract::State(creds): axum::extract::State>, req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - if let Some(header) = req.headers().get(axum::http::header::AUTHORIZATION) { - match header.to_str() { - Ok(value) => match parse_basic_auth_header(value) { - Ok((user, _)) => tracing::debug!("rpc auth header parsed for user {user}"), - Err(e) => tracing::debug!("rpc auth header parse failed: {e}"), - }, - Err(_) => tracing::debug!("rpc auth header is not valid ascii"), + let Some(header) = req.headers().get(axum::http::header::AUTHORIZATION) else { + tracing::debug!("rpc auth header missing; rejecting"); + return unauthorized(); + }; + let value = match header.to_str() { + Ok(s) => s, + Err(_) => { + tracing::debug!("rpc auth header is not valid ascii; rejecting"); + return unauthorized(); } + }; + let (user, pass) = match parse_basic_auth_header(value) { + Ok(pair) => pair, + Err(e) => { + tracing::debug!("rpc auth header parse failed: {e}; rejecting"); + return unauthorized(); + } + }; + if !creds.matches(&user, &pass) { + tracing::debug!("rpc auth credentials mismatched for user {user}; rejecting"); + return unauthorized(); } + tracing::debug!("rpc auth ok for user {user}"); next.run(req).await } +fn unauthorized() -> axum::response::Response { + axum::response::Response::builder() + .status(axum::http::StatusCode::UNAUTHORIZED) + .header( + axum::http::header::WWW_AUTHENTICATE, + r#"Basic realm="jsonrpc""#, + ) + .body(axum::body::Body::empty()) + .expect("static 401 response is always well-formed") +} + /// Parse an HTTP `Authorization: Basic ` header value into `(user, pass)`. /// /// Mirrors Bitcoin Core: the `"Basic "` prefix check is case-sensitive, the @@ -146,6 +173,41 @@ pub(crate) fn parse_basic_auth_header( Ok((user.to_string(), pass.to_string())) } +/// Configured RPC credentials for this process. The middleware compares each +/// inbound `Authorization: Basic` request against the stored value via +/// [`Credentials::matches`]. +pub(crate) enum Credentials { + /// Cookie auth. Stores the full `__cookie__:` line as written to + /// disk by [`generate_cookie`]. + Cookie(String), +} + +impl Credentials { + /// True if the supplied basic-auth `user`/`pass` pair authenticates + /// against the configured credentials. All comparisons are constant-time. + pub(crate) fn matches(&self, user: &str, pass: &str) -> bool { + match self { + Self::Cookie(expected) => { + constant_time_eq(format!("{user}:{pass}").as_bytes(), expected.as_bytes()) + } + } + } +} + +/// Constant-time byte slice comparison. Returns `false` immediately on length +/// mismatch (lengths of both comparands are public), then XORs every byte into +/// an accumulator before returning. +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut acc: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + acc |= x ^ y; + } + acc == 0 +} + /// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown /// is idempotent. Caller must only invoke this after a successful /// [`generate_cookie`] in this process. @@ -181,14 +243,13 @@ mod tests { #[test] fn generate_cookie_writes_expected_format() { let path = tmp_cookie_path("format"); - generate_cookie(&path).expect("cookie write should succeed in test"); + let auth = generate_cookie(&path).expect("cookie write should succeed in test"); - let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); assert!( - written.starts_with("__cookie__:"), - "cookie file missing prefix: {written}" + auth.starts_with("__cookie__:"), + "auth string missing prefix: {auth}" ); - let token = written + let token = auth .strip_prefix("__cookie__:") .expect("generated cookie always begins with __cookie__:"); assert_eq!( @@ -204,6 +265,12 @@ mod tests { "token should be lowercase hex: {token}", ); + let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); + assert_eq!( + written, auth, + "file content should match returned auth string" + ); + fs::remove_file(&path).ok(); } @@ -222,10 +289,8 @@ mod tests { fn generate_cookie_produces_distinct_tokens() { let path1 = tmp_cookie_path("distinct1"); let path2 = tmp_cookie_path("distinct2"); - generate_cookie(&path1).expect("cookie write should succeed in test"); - generate_cookie(&path2).expect("cookie write should succeed in test"); - let auth1 = fs::read_to_string(&path1).expect("test wrote path1 above"); - let auth2 = fs::read_to_string(&path2).expect("test wrote path2 above"); + let auth1 = generate_cookie(&path1).expect("cookie write should succeed in test"); + let auth2 = generate_cookie(&path2).expect("cookie write should succeed in test"); assert_ne!( auth1, auth2, "two consecutive calls produced identical tokens" @@ -239,12 +304,11 @@ mod tests { fn generate_cookie_overwrites_existing_file() { let path = tmp_cookie_path("overwrite"); fs::write(&path, "stale-content").expect("test temp path should be writable"); - generate_cookie(&path).expect("cookie write should succeed in test"); + let auth = generate_cookie(&path).expect("cookie write should succeed in test"); let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); - assert_ne!(written, "stale-content", "stale content was not replaced"); - assert!( - written.starts_with("__cookie__:"), - "replacement is not a cookie line: {written}" + assert_eq!( + written, auth, + "file content should match returned auth string" ); fs::remove_file(&path).ok(); @@ -373,6 +437,41 @@ mod tests { ); } + #[test] + fn constant_time_eq_returns_true_for_equal_bytes() { + assert!(constant_time_eq(b"abcdef", b"abcdef")); + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn constant_time_eq_returns_false_for_different_bytes() { + assert!(!constant_time_eq(b"abcdef", b"abcdeg")); + assert!(!constant_time_eq(b"abcdef", b"xbcdef")); + } + + #[test] + fn constant_time_eq_returns_false_for_length_mismatch() { + assert!(!constant_time_eq(b"abc", b"abcd")); + assert!(!constant_time_eq(b"abcd", b"abc")); + assert!(!constant_time_eq(b"", b"a")); + } + + #[test] + fn cookie_credentials_match_their_own_user_and_pass() { + let path = tmp_cookie_path("creds_cookie"); + let auth = generate_cookie(&path).expect("cookie write should succeed in test"); + let creds = Credentials::Cookie(auth.clone()); + + let (user, pass) = auth + .split_once(':') + .expect("generated auth string always contains a ':' separator"); + assert!(creds.matches(user, pass)); + assert!(!creds.matches(user, "wrong")); + assert!(!creds.matches("wronguser", pass)); + + fs::remove_file(&path).ok(); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index eb01b509e..6fc728815 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -665,6 +665,7 @@ impl RpcImpl { log_path: impl AsRef, user_agent: String, proxy: Option, + credentials: Arc, ) { let address = address.unwrap_or_else(|| { format!("127.0.0.1:{}", Self::get_port(&network)) @@ -695,7 +696,10 @@ impl RpcImpl { .allow_private_network(true) .allow_methods([Method::POST, Method::HEAD]), ) - .layer(axum::middleware::from_fn(super::auth::auth_middleware)) + .layer(axum::middleware::from_fn_with_state( + credentials, + super::auth::auth_middleware, + )) .with_state(Arc::new(RpcImpl { chain, wallet, diff --git a/crates/floresta-rpc/src/lib.rs b/crates/floresta-rpc/src/lib.rs index 4f32f94c1..a32bb806d 100644 --- a/crates/floresta-rpc/src/lib.rs +++ b/crates/floresta-rpc/src/lib.rs @@ -42,6 +42,7 @@ mod tests { use rcgen::generate_simple_self_signed; use crate::jsonrpc_client::Client; + use crate::jsonrpc_client::JsonRPCConfig; use crate::rpc::FlorestaRPC; use crate::rpc_types::GetBlockHeaderRes; use crate::rpc_types::GetBlockRes; @@ -108,7 +109,16 @@ mod tests { .spawn() .unwrap_or_else(|e| panic!("Couldn't launch florestad at {florestad_path}: {e}")); - let client = Client::new(format!("http://127.0.0.1:{port}")); + // florestad writes the cookie file before binding the RPC port, so poll + // for it to appear and use it to authenticate the test client. + let cookie_path = format!("{dirname}/regtest/.cookie"); + let (user, pass) = read_cookie(&cookie_path, &mut fld); + + let client = Client::new_with_config(JsonRPCConfig { + url: format!("http://127.0.0.1:{port}"), + user: Some(user), + pass: Some(pass), + }); let mut retries = 10; loop { @@ -130,6 +140,25 @@ mod tests { (Florestad { proc: fld }, client) } + /// Poll for the RPC cookie file and split it into (user, pass) on the first + /// `:`. Kills `fld` and panics if the cookie does not appear within 30s. + fn read_cookie(path: &str, fld: &mut Child) -> (String, String) { + let mut retries = 30; + loop { + if let Ok(line) = fs::read_to_string(path) { + if let Some((user, pass)) = line.split_once(':') { + return (user.to_string(), pass.to_string()); + } + } + if retries == 0 { + fld.kill().unwrap(); + panic!("Cookie file {path} did not appear within 30 seconds"); + } + retries -= 1; + sleep(Duration::from_secs(1)); + } + } + fn get_available_port() -> u16 { // Limit `listener` scope to release port let port = { diff --git a/tests/test_framework/node.py b/tests/test_framework/node.py index d754cfff9..9ec0b1ed9 100644 --- a/tests/test_framework/node.py +++ b/tests/test_framework/node.py @@ -8,6 +8,7 @@ including their daemon processes, RPC interfaces, and configurations. """ +import os from enum import Enum from typing import List, Tuple, Optional @@ -197,6 +198,20 @@ def set_rpc_config(self, value: ConfigRPC): self.daemon.set_rpc_config(value) self.rpc.set_config(value) + def _load_floresta_cookie_credentials(self): + """ + Read the cookie file florestad wrote at startup and populate the RPC + client's basic-auth credentials. No-op for other node variants. + """ + if self._variant != NodeType.FLORESTAD: + return + cookie_path = os.path.join(self.daemon.data_dir, "regtest", ".cookie") + with open(cookie_path, "r", encoding="utf-8") as fh: + contents = fh.read() + user, password = contents.split(":", 1) + self.rpc.config.user = user + self.rpc.config.password = password + def set_extra_args(self, value: List[str]): """Setter for `extra_args` property""" if self.static_values: @@ -280,6 +295,7 @@ def start(self): self.daemon.start() self.rpc.wait_on_socket(opened=True) + self._load_floresta_cookie_credentials() # Test if the node is already responding to RPC calls. self.rpc.get_blockchain_info() From f7da517a71c3082add83c81c6b5e398a92136513 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Wed, 3 Jun 2026 17:53:32 +0100 Subject: [PATCH 5/9] feat(rpc): support -rpcuser/-rpcpassword config Add rpc_user/rpc_password to florestad::Config, exposed via --rpc-user/--rpc-password CLI flags and an [rpc] section in the TOML config file. CLI values take precedence over file values. Rename Credentials to Auth and add an Auth::UserPass variant that stores the user:pass pair as a single pre-formatted line, sharing the constant-time matches() path with Auth::Cookie. When rpc_password is set, Florestad::start uses Auth::UserPass and skips cookie generation, mirroring Bitcoin Core's mutual exclusion between cookie auth and configured passwords. The cookie_generated flag stays unset so wait_shutdown also skips deletion. Refs: #651 --- bin/florestad/src/cli.rs | 8 ++++ bin/florestad/src/main.rs | 4 ++ crates/floresta-node/src/config_file.rs | 10 +++++ crates/floresta-node/src/florestad.rs | 46 ++++++++++++++++++--- crates/floresta-node/src/json_rpc/auth.rs | 27 ++++++++---- crates/floresta-node/src/json_rpc/server.rs | 2 +- 6 files changed, 84 insertions(+), 13 deletions(-) diff --git a/bin/florestad/src/cli.rs b/bin/florestad/src/cli.rs index 2e778f38b..230188e5c 100644 --- a/bin/florestad/src/cli.rs +++ b/bin/florestad/src/cli.rs @@ -108,6 +108,14 @@ pub struct Cli { /// The address where our json-rpc server should listen to, in the format `
[:]` pub rpc_address: Option, + #[arg(long, value_name = "USER")] + /// Username for `-rpcuser`/`-rpcpassword` auth. + pub rpc_user: Option, + + #[arg(long, value_name = "PASS")] + /// Password for `-rpcuser`/`-rpcpassword` auth; setting it disables cookie auth. + pub rpc_password: Option, + #[arg(long, value_name = "HEIGHT")] /// Download block filters starting at this height. Negative numbers are relative to the current tip. pub filters_start_height: Option, diff --git a/bin/florestad/src/main.rs b/bin/florestad/src/main.rs index 66280b3c4..1c19a630b 100644 --- a/bin/florestad/src/main.rs +++ b/bin/florestad/src/main.rs @@ -82,6 +82,10 @@ fn main() { zmq_address: params.zmq_address, #[cfg(feature = "json-rpc")] json_rpc_address: params.rpc_address, + #[cfg(feature = "json-rpc")] + rpc_user: params.rpc_user, + #[cfg(feature = "json-rpc")] + rpc_password: params.rpc_password, generate_cert: params.generate_cert, wallet_descriptor: params.wallet_descriptor, filters_start_height: params.filters_start_height, diff --git a/crates/floresta-node/src/config_file.rs b/crates/floresta-node/src/config_file.rs index 38edc5f68..24c2b17ac 100644 --- a/crates/floresta-node/src/config_file.rs +++ b/crates/floresta-node/src/config_file.rs @@ -14,9 +14,19 @@ pub struct Wallet { pub addresses: Option>, } +#[cfg(feature = "json-rpc")] +#[derive(Default, Debug, Deserialize)] +pub struct Rpc { + pub user: Option, + pub password: Option, +} + #[derive(Default, Debug, Deserialize)] pub struct ConfigFile { pub wallet: Wallet, + #[cfg(feature = "json-rpc")] + #[serde(default)] + pub rpc: Rpc, } impl ConfigFile { diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index bb5a40bba..30cbf7601 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -166,6 +166,14 @@ pub struct Config { /// The address our json-rpc should listen to pub json_rpc_address: Option, + /// Username for `-rpcuser`/`-rpcpassword` auth. + #[cfg(feature = "json-rpc")] + pub rpc_user: Option, + + /// Password for `-rpcuser`/`-rpcpassword` auth; setting it disables cookie auth. + #[cfg(feature = "json-rpc")] + pub rpc_password: Option, + /// Whether we should write logs to `stdout`. pub log_to_stdout: bool, @@ -241,6 +249,10 @@ impl Config { connect: Vec::new(), #[cfg(feature = "json-rpc")] json_rpc_address: None, + #[cfg(feature = "json-rpc")] + rpc_user: None, + #[cfg(feature = "json-rpc")] + rpc_password: None, log_to_stdout: false, log_to_file: false, assume_utreexo: false, @@ -471,11 +483,17 @@ impl Florestad { // JSON-RPC #[cfg(feature = "json-rpc")] { - let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); - let cookie = json_rpc::auth::generate_cookie(&cookie_path)?; - let _ = self.cookie_generated.set(()); - info!("RPC cookie file written to {}", cookie_path.display()); - let credentials = Arc::new(json_rpc::auth::Credentials::Cookie(cookie)); + let credentials = if let Some(pass) = self.get_rpc_password() { + let user = self.get_rpc_user().unwrap_or_default(); + info!("RPC password auth configured for user '{user}'"); + Arc::new(json_rpc::auth::Auth::UserPass(format!("{user}:{pass}"))) + } else { + let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); + let cookie = json_rpc::auth::generate_cookie(&cookie_path)?; + let _ = self.cookie_generated.set(()); + info!("RPC cookie file written to {}", cookie_path.display()); + Arc::new(json_rpc::auth::Auth::Cookie(cookie)) + }; let server = tokio::spawn(json_rpc::server::RpcImpl::create( blockchain_state.clone(), @@ -776,6 +794,24 @@ impl Florestad { Ok(wallet) } + /// Get the configured RPC user; CLI/struct value wins over the config file. + #[cfg(feature = "json-rpc")] + fn get_rpc_user(&self) -> Option { + self.config + .rpc_user + .clone() + .or_else(|| self.get_config_file().rpc.user) + } + + /// Get the configured RPC password; CLI/struct value wins over the config file. + #[cfg(feature = "json-rpc")] + fn get_rpc_password(&self) -> Option { + self.config + .rpc_password + .clone() + .or_else(|| self.get_config_file().rpc.password) + } + /// Get the wallet descriptors from the config file fn get_descriptors(&self) -> Vec { self.config diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index bb912db49..27b833529 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -101,12 +101,12 @@ impl fmt::Display for BasicAuthHeaderError { impl std::error::Error for BasicAuthHeaderError {} -/// Axum middleware that gates each request on the configured [`Credentials`]. +/// Axum middleware that gates each request on the configured [`Auth`]. /// Missing, malformed, non-ASCII, or non-matching `Authorization: Basic` /// headers all return HTTP 401 with `WWW-Authenticate: Basic realm="jsonrpc"` /// per RFC 7235. Matching requests pass through to the handler. pub(crate) async fn auth_middleware( - axum::extract::State(creds): axum::extract::State>, + axum::extract::State(creds): axum::extract::State>, req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { @@ -175,19 +175,23 @@ pub(crate) fn parse_basic_auth_header( /// Configured RPC credentials for this process. The middleware compares each /// inbound `Authorization: Basic` request against the stored value via -/// [`Credentials::matches`]. -pub(crate) enum Credentials { +/// [`Auth::matches`]. +pub(crate) enum Auth { /// Cookie auth. Stores the full `__cookie__:` line as written to /// disk by [`generate_cookie`]. Cookie(String), + + /// Plaintext `-rpcuser`/`-rpcpassword` pair stored as a pre-formatted + /// `user:pass` line so it shares the same comparison shape as `Cookie`. + UserPass(String), } -impl Credentials { +impl Auth { /// True if the supplied basic-auth `user`/`pass` pair authenticates /// against the configured credentials. All comparisons are constant-time. pub(crate) fn matches(&self, user: &str, pass: &str) -> bool { match self { - Self::Cookie(expected) => { + Self::Cookie(expected) | Self::UserPass(expected) => { constant_time_eq(format!("{user}:{pass}").as_bytes(), expected.as_bytes()) } } @@ -460,7 +464,7 @@ mod tests { fn cookie_credentials_match_their_own_user_and_pass() { let path = tmp_cookie_path("creds_cookie"); let auth = generate_cookie(&path).expect("cookie write should succeed in test"); - let creds = Credentials::Cookie(auth.clone()); + let creds = Auth::Cookie(auth.clone()); let (user, pass) = auth .split_once(':') @@ -472,6 +476,15 @@ mod tests { fs::remove_file(&path).ok(); } + #[test] + fn user_pass_credentials_match_their_own_user_and_pass() { + let creds = Auth::UserPass(String::from("alice:hunter2")); + + assert!(creds.matches("alice", "hunter2")); + assert!(!creds.matches("alice", "wrong")); + assert!(!creds.matches("eve", "hunter2")); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 6fc728815..432bc4691 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -665,7 +665,7 @@ impl RpcImpl { log_path: impl AsRef, user_agent: String, proxy: Option, - credentials: Arc, + credentials: Arc, ) { let address = address.unwrap_or_else(|| { format!("127.0.0.1:{}", Self::get_port(&network)) From 6e7dea2e71295fe7d4901bb65f359a726563780c Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Thu, 4 Jun 2026 19:19:03 +0100 Subject: [PATCH 6/9] feat(rpc): hash configured password with salted HMAC Replace Auth::UserPass with Auth::Hashed(Vec) and hash the configured -rpcuser/-rpcpassword pair at startup. Florestad keeps only (user, salt_hex, hash_hex) in memory; the plaintext password is discarded after the HMAC. RpcAuth::from_password rolls a 16-byte salt, hex-encodes it (32 ASCII chars), and HMAC-SHA256s the password. RpcAuth::verify recomputes the HMAC on each request and constant-time-compares both the username and the digest. The HMAC key is the salt's hex string as ASCII bytes (NOT the 16 decoded bytes), matching Bitcoin Core's share/rpcauth/rpcauth.py. A pinned test vector asserts the digest matches rpcauth.py for a known (salt, password) pair. Refs: #651 --- crates/floresta-node/src/florestad.rs | 4 +- crates/floresta-node/src/json_rpc/auth.rs | 116 +++++++++++++++++++--- 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 30cbf7601..f152208cc 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -486,7 +486,9 @@ impl Florestad { let credentials = if let Some(pass) = self.get_rpc_password() { let user = self.get_rpc_user().unwrap_or_default(); info!("RPC password auth configured for user '{user}'"); - Arc::new(json_rpc::auth::Auth::UserPass(format!("{user}:{pass}"))) + Arc::new(json_rpc::auth::Auth::Hashed(vec![ + json_rpc::auth::RpcAuth::from_password(&user, &pass), + ])) } else { let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); let cookie = json_rpc::auth::generate_cookie(&cookie_path)?; diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index 27b833529..2b217e0a6 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -25,6 +25,11 @@ use std::path::PathBuf; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; +use bitcoin::hashes::Hash; +use bitcoin::hashes::HashEngine; +use bitcoin::hashes::Hmac; +use bitcoin::hashes::HmacEngine; +use bitcoin::hashes::sha256; use bitcoin::hex::DisplayHex; use rand::Rng; @@ -177,27 +182,55 @@ pub(crate) fn parse_basic_auth_header( /// inbound `Authorization: Basic` request against the stored value via /// [`Auth::matches`]. pub(crate) enum Auth { - /// Cookie auth. Stores the full `__cookie__:` line as written to - /// disk by [`generate_cookie`]. + /// Full `__cookie__:` line as written to disk. Cookie(String), - /// Plaintext `-rpcuser`/`-rpcpassword` pair stored as a pre-formatted - /// `user:pass` line so it shares the same comparison shape as `Cookie`. - UserPass(String), + /// Configured users with salted HMAC-SHA256 password hashes. + Hashed(Vec), } impl Auth { - /// True if the supplied basic-auth `user`/`pass` pair authenticates - /// against the configured credentials. All comparisons are constant-time. + /// True if the basic-auth `user`/`pass` pair authenticates. Constant-time. pub(crate) fn matches(&self, user: &str, pass: &str) -> bool { match self { - Self::Cookie(expected) | Self::UserPass(expected) => { + Self::Cookie(expected) => { constant_time_eq(format!("{user}:{pass}").as_bytes(), expected.as_bytes()) } + Self::Hashed(entries) => entries.iter().any(|a| a.verify(user, pass)), } } } +/// One salted-hash credential entry: `(user, salt_hex, hash_hex)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RpcAuth { + pub user: String, + pub salt_hex: String, + pub hash_hex: String, +} + +impl RpcAuth { + /// Roll a fresh salt and HMAC the password. + pub(crate) fn from_password(user: &str, pass: &str) -> Self { + let salt_hex = generate_salt_hex(); + let hash_hex = hmac_password(&salt_hex, pass); + Self { + user: user.to_string(), + salt_hex, + hash_hex, + } + } + + /// Constant-time check: user matches and HMAC(pass) equals the stored hash. + pub(crate) fn verify(&self, user: &str, pass: &str) -> bool { + if !constant_time_eq(user.as_bytes(), self.user.as_bytes()) { + return false; + } + let computed = hmac_password(&self.salt_hex, pass); + constant_time_eq(computed.as_bytes(), self.hash_hex.as_bytes()) + } +} + /// Constant-time byte slice comparison. Returns `false` immediately on length /// mismatch (lengths of both comparands are public), then XORs every byte into /// an accumulator before returning. @@ -212,6 +245,24 @@ pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { acc == 0 } +/// Generate a fresh 16-byte salt, lowercase-hex-encoded (32 ASCII chars). +fn generate_salt_hex() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill(&mut bytes); + bytes.to_lower_hex_string() +} + +/// HMAC-SHA256 with `salt_hex.as_bytes()` as the key (NOT decoded bytes; this +/// is the interop hinge with Bitcoin Core's `rpcauth.py`) and `pass` as the +/// message; returns the 32-byte digest as 64 lowercase hex chars. +fn hmac_password(salt_hex: &str, pass: &str) -> String { + let mut engine = HmacEngine::::new(salt_hex.as_bytes()); + engine.input(pass.as_bytes()); + Hmac::::from_engine(engine) + .to_byte_array() + .to_lower_hex_string() +} + /// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown /// is idempotent. Caller must only invoke this after a successful /// [`generate_cookie`] in this process. @@ -477,14 +528,55 @@ mod tests { } #[test] - fn user_pass_credentials_match_their_own_user_and_pass() { - let creds = Auth::UserPass(String::from("alice:hunter2")); - + fn hashed_credentials_match_any_configured_entry() { + let creds = Auth::Hashed(vec![ + RpcAuth::from_password("alice", "hunter2"), + RpcAuth::from_password("bob", "letmein"), + ]); assert!(creds.matches("alice", "hunter2")); - assert!(!creds.matches("alice", "wrong")); + assert!(creds.matches("bob", "letmein")); + assert!(!creds.matches("alice", "hunter3")); + assert!(!creds.matches("bob", "hunter2")); assert!(!creds.matches("eve", "hunter2")); } + #[test] + fn rpcauth_from_password_round_trips_with_verify() { + let auth = RpcAuth::from_password("alice", "hunter2"); + assert_eq!(auth.user, "alice"); + assert_eq!( + auth.salt_hex.len(), + 32, + "salt should be 16 bytes hex-encoded" + ); + assert_eq!( + auth.hash_hex.len(), + 64, + "hash should be 32 bytes hex-encoded" + ); + assert!(auth.verify("alice", "hunter2")); + assert!(!auth.verify("alice", "wrong")); + assert!(!auth.verify("bob", "hunter2")); + } + + #[test] + fn rpcauth_from_password_uses_fresh_salt_per_call() { + let a = RpcAuth::from_password("alice", "hunter2"); + let b = RpcAuth::from_password("alice", "hunter2"); + assert_ne!(a.salt_hex, b.salt_hex); + assert_ne!(a.hash_hex, b.hash_hex); + } + + #[test] + fn hmac_password_matches_rpcauth_py_vector() { + // Generated with `python3 share/rpcauth/rpcauth.py alice` from + // bitcoin/bitcoin master. Pins the salt-hex-as-ASCII-bytes interop hinge. + let salt_hex = "cf2c6b493e4690de904306d1a82ef1cc"; + let pass = "Ys-WMXs6znL6q2BH9yjh77k9keytl4JpCshiapNCMyg"; + let expected = "9e0770d953ba59dcd5cf447615717764936d48b01d4428d251628f0156088901"; + assert_eq!(hmac_password(salt_hex, pass), expected); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { From 2ee41022279775b01de9c2c10f0fd5514d5afaff Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Fri, 5 Jun 2026 00:49:11 +0100 Subject: [PATCH 7/9] feat(rpc): support -rpcauth multi-user config Add RpcAuth::parse for the :$ line format from Bitcoin Core's share/rpcauth/rpcauth.py. Collapse Auth into one Vec matching Core's g_rpcauth model. Cookie auth is now another hashed entry: generate_cookie returns (user, token) and the cookie's token is hashed and pushed alongside any -rpcuser/-rpcpassword and -rpcauth entries. Config gains rpc_auth: Vec via --rpc-auth (repeatable) and an auth = [...] key under [rpc] in the TOML file. Refs: #651 --- bin/florestad/src/cli.rs | 6 + bin/florestad/src/main.rs | 2 + crates/floresta-node/src/config_file.rs | 2 + crates/floresta-node/src/error.rs | 6 + crates/floresta-node/src/florestad.rs | 51 +++++- crates/floresta-node/src/json_rpc/auth.rs | 195 ++++++++++++++++------ 6 files changed, 203 insertions(+), 59 deletions(-) diff --git a/bin/florestad/src/cli.rs b/bin/florestad/src/cli.rs index 230188e5c..e550d3632 100644 --- a/bin/florestad/src/cli.rs +++ b/bin/florestad/src/cli.rs @@ -116,6 +116,12 @@ pub struct Cli { /// Password for `-rpcuser`/`-rpcpassword` auth; setting it disables cookie auth. pub rpc_password: Option, + #[arg(long, value_name = "LINE", action = clap::ArgAction::Append)] + /// Pre-hashed credential entry in `:$` format. + /// Generate with Bitcoin Core's `share/rpcauth/rpcauth.py`. Repeat for + /// multiple users. + pub rpc_auth: Vec, + #[arg(long, value_name = "HEIGHT")] /// Download block filters starting at this height. Negative numbers are relative to the current tip. pub filters_start_height: Option, diff --git a/bin/florestad/src/main.rs b/bin/florestad/src/main.rs index 1c19a630b..9ed58f19a 100644 --- a/bin/florestad/src/main.rs +++ b/bin/florestad/src/main.rs @@ -86,6 +86,8 @@ fn main() { rpc_user: params.rpc_user, #[cfg(feature = "json-rpc")] rpc_password: params.rpc_password, + #[cfg(feature = "json-rpc")] + rpc_auth: params.rpc_auth, generate_cert: params.generate_cert, wallet_descriptor: params.wallet_descriptor, filters_start_height: params.filters_start_height, diff --git a/crates/floresta-node/src/config_file.rs b/crates/floresta-node/src/config_file.rs index 24c2b17ac..c57947cb5 100644 --- a/crates/floresta-node/src/config_file.rs +++ b/crates/floresta-node/src/config_file.rs @@ -19,6 +19,8 @@ pub struct Wallet { pub struct Rpc { pub user: Option, pub password: Option, + #[serde(default)] + pub auth: Vec, } #[derive(Default, Debug, Deserialize)] diff --git a/crates/floresta-node/src/error.rs b/crates/floresta-node/src/error.rs index 90254bc82..cdfcb1cb6 100644 --- a/crates/floresta-node/src/error.rs +++ b/crates/floresta-node/src/error.rs @@ -121,6 +121,9 @@ pub enum FlorestadError { /// Load a flat chain store error. CouldNotLoadFlatChainStore(BlockchainError), + + /// A malformed `-rpcauth` entry was provided at startup. + InvalidRpcAuth(String), } impl Display for FlorestadError { @@ -225,6 +228,9 @@ impl Display for FlorestadError { FlorestadError::CouldNotLoadFlatChainStore(err) => { write!(f, "Failure while loading flat chainstore: {err:?}") } + FlorestadError::InvalidRpcAuth(line) => { + write!(f, "Invalid -rpcauth argument: {line}") + } } } } diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index f152208cc..217b31ce9 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -174,6 +174,11 @@ pub struct Config { #[cfg(feature = "json-rpc")] pub rpc_password: Option, + /// `-rpcauth` lines: `:$`. Repeated zero or more + /// times. Merged additively with config-file entries. + #[cfg(feature = "json-rpc")] + pub rpc_auth: Vec, + /// Whether we should write logs to `stdout`. pub log_to_stdout: bool, @@ -253,6 +258,8 @@ impl Config { rpc_user: None, #[cfg(feature = "json-rpc")] rpc_password: None, + #[cfg(feature = "json-rpc")] + rpc_auth: Vec::new(), log_to_stdout: false, log_to_file: false, assume_utreexo: false, @@ -483,19 +490,38 @@ impl Florestad { // JSON-RPC #[cfg(feature = "json-rpc")] { - let credentials = if let Some(pass) = self.get_rpc_password() { + let mut entries: Vec = Vec::new(); + + if let Some(pass) = self.get_rpc_password() { let user = self.get_rpc_user().unwrap_or_default(); info!("RPC password auth configured for user '{user}'"); - Arc::new(json_rpc::auth::Auth::Hashed(vec![ - json_rpc::auth::RpcAuth::from_password(&user, &pass), - ])) + entries.push(json_rpc::auth::RpcAuth::from_password(&user, &pass)); } else { let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); - let cookie = json_rpc::auth::generate_cookie(&cookie_path)?; + let (cookie_user, cookie_pass) = json_rpc::auth::generate_cookie(&cookie_path)?; let _ = self.cookie_generated.set(()); info!("RPC cookie file written to {}", cookie_path.display()); - Arc::new(json_rpc::auth::Auth::Cookie(cookie)) - }; + entries.push(json_rpc::auth::RpcAuth::from_password( + &cookie_user, + &cookie_pass, + )); + } + + for line in self.get_rpc_auth() { + match json_rpc::auth::RpcAuth::parse(&line) { + Ok(entry) => entries.push(entry), + Err(e) => { + error!("{e}"); + return Err(FlorestadError::InvalidRpcAuth(line)); + } + } + } + + if entries.len() > 1 { + info!("RPC: {} rpcauth entries loaded", entries.len() - 1); + } + + let credentials = Arc::new(json_rpc::auth::Auth::new(entries)); let server = tokio::spawn(json_rpc::server::RpcImpl::create( blockchain_state.clone(), @@ -814,6 +840,17 @@ impl Florestad { .or_else(|| self.get_config_file().rpc.password) } + /// Get all `-rpcauth` lines; CLI vec is merged additively with config-file vec. + #[cfg(feature = "json-rpc")] + fn get_rpc_auth(&self) -> Vec { + self.config + .rpc_auth + .iter() + .chain(self.get_config_file().rpc.auth.iter()) + .cloned() + .collect() + } + /// Get the wallet descriptors from the config file fn get_descriptors(&self) -> Vec { self.config diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index 2b217e0a6..0813cfed2 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -45,15 +45,18 @@ const COOKIE_TOKEN_BYTES: usize = 32; /// Upper bound on the inbound `Authorization` header to cap base64 decode allocation. const MAX_AUTH_HEADER_LEN: usize = 16 * 1024; -/// Generate a fresh cookie, write it to `path` with no trailing newline, and -/// return the `__cookie__:` line for in-process validation. +/// Generate a fresh cookie, write `:` to `path` with no +/// trailing newline, and return `(user, token_hex)` so the caller can feed the +/// token directly into the in-memory auth vector byte-identical to what landed +/// in the file. /// /// Writes go to `.tmp` first with mode `0600` on Unix, then atomically /// rename over `path`. A pre-existing cookie file is silently overwritten. -pub(crate) fn generate_cookie(path: &Path) -> io::Result { +pub(crate) fn generate_cookie(path: &Path) -> io::Result<(String, String)> { let mut token = [0u8; COOKIE_TOKEN_BYTES]; rand::rng().fill(&mut token); - let auth = format!("{COOKIE_USER}:{}", token.to_lower_hex_string()); + let token_hex = token.to_lower_hex_string(); + let line = format!("{COOKIE_USER}:{token_hex}"); let tmp = tmp_path(path); @@ -66,12 +69,12 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result { } let mut file = opts.open(&tmp)?; - file.write_all(auth.as_bytes())?; + file.write_all(line.as_bytes())?; drop(file); fs::rename(&tmp, path)?; - Ok(auth) + Ok((COOKIE_USER.to_string(), token_hex)) } /// Errors produced by [`parse_basic_auth_header`]. @@ -178,26 +181,23 @@ pub(crate) fn parse_basic_auth_header( Ok((user.to_string(), pass.to_string())) } -/// Configured RPC credentials for this process. The middleware compares each -/// inbound `Authorization: Basic` request against the stored value via -/// [`Auth::matches`]. -pub(crate) enum Auth { - /// Full `__cookie__:` line as written to disk. - Cookie(String), - - /// Configured users with salted HMAC-SHA256 password hashes. - Hashed(Vec), +/// Configured RPC credentials for this process: cookie, `-rpcuser`/`-rpcpassword`, +/// and any `-rpcauth` entries all live in a single vector. First match wins. +/// Mirrors Bitcoin Core's `g_rpcauth` (httprpc.cpp:36). +pub(crate) struct Auth { + entries: Vec, } impl Auth { - /// True if the basic-auth `user`/`pass` pair authenticates. Constant-time. + /// Build from a pre-collected vector of entries. + pub(crate) fn new(entries: Vec) -> Self { + Self { entries } + } + + /// True if the basic-auth `user`/`pass` pair authenticates against any + /// configured entry. Each entry's check is constant-time. pub(crate) fn matches(&self, user: &str, pass: &str) -> bool { - match self { - Self::Cookie(expected) => { - constant_time_eq(format!("{user}:{pass}").as_bytes(), expected.as_bytes()) - } - Self::Hashed(entries) => entries.iter().any(|a| a.verify(user, pass)), - } + self.entries.iter().any(|a| a.verify(user, pass)) } } @@ -221,6 +221,35 @@ impl RpcAuth { } } + /// Parse a `:$` line, mirroring Bitcoin Core. + pub(crate) fn parse(line: &str) -> Result { + let colon_parts: Vec<&str> = line.split(':').collect(); + if colon_parts.len() != 2 { + return Err(RpcAuthParseError::MalformedLine); + } + let user = colon_parts[0]; + let salt_hash: Vec<&str> = colon_parts[1].split('$').collect(); + if salt_hash.len() != 2 { + return Err(RpcAuthParseError::MalformedLine); + } + let salt_hex = salt_hash[0]; + let hash_hex = salt_hash[1]; + if user.is_empty() || hash_hex.is_empty() { + return Err(RpcAuthParseError::EmptyUserOrHash); + } + if !hash_hex + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(RpcAuthParseError::NonLowercaseHexHash); + } + Ok(Self { + user: user.to_string(), + salt_hex: salt_hex.to_string(), + hash_hex: hash_hex.to_string(), + }) + } + /// Constant-time check: user matches and HMAC(pass) equals the stored hash. pub(crate) fn verify(&self, user: &str, pass: &str) -> bool { if !constant_time_eq(user.as_bytes(), self.user.as_bytes()) { @@ -231,6 +260,26 @@ impl RpcAuth { } } +/// Errors from [`RpcAuth::parse`]. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RpcAuthParseError { + /// Wrong number of `:` or `$` separators. + MalformedLine, + /// Username or hash field is empty. + EmptyUserOrHash, + /// Hash field contains non-`[0-9a-f]` characters. + NonLowercaseHexHash, +} + +impl fmt::Display for RpcAuthParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Match Bitcoin Core's wording (httprpc.cpp:308) for the operator log. + write!(f, "Invalid -rpcauth argument.") + } +} + +impl std::error::Error for RpcAuthParseError {} + /// Constant-time byte slice comparison. Returns `false` immediately on length /// mismatch (lengths of both comparands are public), then XORs every byte into /// an accumulator before returning. @@ -298,15 +347,9 @@ mod tests { #[test] fn generate_cookie_writes_expected_format() { let path = tmp_cookie_path("format"); - let auth = generate_cookie(&path).expect("cookie write should succeed in test"); + let (user, token) = generate_cookie(&path).expect("cookie write should succeed in test"); - assert!( - auth.starts_with("__cookie__:"), - "auth string missing prefix: {auth}" - ); - let token = auth - .strip_prefix("__cookie__:") - .expect("generated cookie always begins with __cookie__:"); + assert_eq!(user, "__cookie__"); assert_eq!( token.len(), 64, @@ -322,8 +365,9 @@ mod tests { let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); assert_eq!( - written, auth, - "file content should match returned auth string" + written, + format!("{user}:{token}"), + "file content should match returned (user, token) joined by ':'", ); fs::remove_file(&path).ok(); @@ -344,12 +388,9 @@ mod tests { fn generate_cookie_produces_distinct_tokens() { let path1 = tmp_cookie_path("distinct1"); let path2 = tmp_cookie_path("distinct2"); - let auth1 = generate_cookie(&path1).expect("cookie write should succeed in test"); - let auth2 = generate_cookie(&path2).expect("cookie write should succeed in test"); - assert_ne!( - auth1, auth2, - "two consecutive calls produced identical tokens" - ); + let (_, t1) = generate_cookie(&path1).expect("cookie write should succeed in test"); + let (_, t2) = generate_cookie(&path2).expect("cookie write should succeed in test"); + assert_ne!(t1, t2, "two consecutive calls produced identical tokens"); fs::remove_file(&path1).ok(); fs::remove_file(&path2).ok(); @@ -359,11 +400,12 @@ mod tests { fn generate_cookie_overwrites_existing_file() { let path = tmp_cookie_path("overwrite"); fs::write(&path, "stale-content").expect("test temp path should be writable"); - let auth = generate_cookie(&path).expect("cookie write should succeed in test"); + let (user, token) = generate_cookie(&path).expect("cookie write should succeed in test"); let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); assert_eq!( - written, auth, - "file content should match returned auth string" + written, + format!("{user}:{token}"), + "file content should match returned (user, token) joined by ':'", ); fs::remove_file(&path).ok(); @@ -512,24 +554,21 @@ mod tests { } #[test] - fn cookie_credentials_match_their_own_user_and_pass() { + fn cookie_entry_matches_its_own_user_and_token() { let path = tmp_cookie_path("creds_cookie"); - let auth = generate_cookie(&path).expect("cookie write should succeed in test"); - let creds = Auth::Cookie(auth.clone()); + let (user, token) = generate_cookie(&path).expect("cookie write should succeed in test"); + let creds = Auth::new(vec![RpcAuth::from_password(&user, &token)]); - let (user, pass) = auth - .split_once(':') - .expect("generated auth string always contains a ':' separator"); - assert!(creds.matches(user, pass)); - assert!(!creds.matches(user, "wrong")); - assert!(!creds.matches("wronguser", pass)); + assert!(creds.matches(&user, &token)); + assert!(!creds.matches(&user, "wrong")); + assert!(!creds.matches("wronguser", &token)); fs::remove_file(&path).ok(); } #[test] - fn hashed_credentials_match_any_configured_entry() { - let creds = Auth::Hashed(vec![ + fn auth_matches_any_configured_entry() { + let creds = Auth::new(vec![ RpcAuth::from_password("alice", "hunter2"), RpcAuth::from_password("bob", "letmein"), ]); @@ -577,6 +616,58 @@ mod tests { assert_eq!(hmac_password(salt_hex, pass), expected); } + #[test] + fn rpcauth_parse_accepts_well_formed_line() { + // Reuses the rpcauth.py vector pinned by hmac_password_matches_rpcauth_py_vector. + let line = "alice:cf2c6b493e4690de904306d1a82ef1cc$9e0770d953ba59dcd5cf447615717764936d48b01d4428d251628f0156088901"; + let parsed = RpcAuth::parse(line).expect("rpcauth.py output should parse"); + assert_eq!(parsed.user, "alice"); + assert_eq!(parsed.salt_hex, "cf2c6b493e4690de904306d1a82ef1cc"); + assert_eq!(parsed.hash_hex.len(), 64); + } + + #[test] + fn rpcauth_parse_rejects_malformed_lines() { + assert_eq!( + RpcAuth::parse("nocolon$hash"), + Err(RpcAuthParseError::MalformedLine) + ); + assert_eq!( + RpcAuth::parse("user:salt"), + Err(RpcAuthParseError::MalformedLine) + ); + assert_eq!( + RpcAuth::parse("user:extra:s$h"), + Err(RpcAuthParseError::MalformedLine) + ); + assert_eq!( + RpcAuth::parse("user:salt$h$extra"), + Err(RpcAuthParseError::MalformedLine) + ); + } + + #[test] + fn rpcauth_parse_rejects_empty_user_or_hash() { + assert_eq!( + RpcAuth::parse(":salt$abcdef"), + Err(RpcAuthParseError::EmptyUserOrHash) + ); + assert_eq!( + RpcAuth::parse("user:salt$"), + Err(RpcAuthParseError::EmptyUserOrHash) + ); + } + + #[test] + fn rpcauth_parse_rejects_uppercase_hash() { + // Bitcoin Core's HexStr only emits lowercase, so any uppercase char in the + // hash field can never match anything legitimate. + assert_eq!( + RpcAuth::parse("user:salt$ABCDEF0123"), + Err(RpcAuthParseError::NonLowercaseHexHash), + ); + } + #[cfg(unix)] #[test] fn generate_cookie_sets_owner_only_mode_on_unix() { From b3fd6d52309b31306d814f1c142f2e51ee2b4ad5 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Fri, 5 Jun 2026 03:55:08 +0100 Subject: [PATCH 8/9] feat(rpc): rate-limit failed authentication attempts On credentials mismatch the middleware logs at warn level with the peer's IP and sleeps 250 ms before returning the 401. Missing or malformed headers still get an instant 401. Peer address is extracted via axum's ConnectInfo, threaded through with into_make_service_with_connect_info::() on the router. Refs: #651 --- crates/floresta-node/src/json_rpc/auth.rs | 9 +++++++-- crates/floresta-node/src/json_rpc/server.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index 0813cfed2..e508ddfcb 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -20,8 +20,10 @@ use std::fs; use std::fs::OpenOptions; use std::io; use std::io::Write; +use std::net::SocketAddr; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -112,9 +114,11 @@ impl std::error::Error for BasicAuthHeaderError {} /// Axum middleware that gates each request on the configured [`Auth`]. /// Missing, malformed, non-ASCII, or non-matching `Authorization: Basic` /// headers all return HTTP 401 with `WWW-Authenticate: Basic realm="jsonrpc"` -/// per RFC 7235. Matching requests pass through to the handler. +/// per RFC 7235. Matching requests pass through to the handler. Wrong +/// credentials trigger a 250 ms rate-limit sleep before the 401 lands. pub(crate) async fn auth_middleware( axum::extract::State(creds): axum::extract::State>, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { @@ -137,7 +141,8 @@ pub(crate) async fn auth_middleware( } }; if !creds.matches(&user, &pass) { - tracing::debug!("rpc auth credentials mismatched for user {user}; rejecting"); + tracing::warn!("incorrect password attempt from {peer}"); + tokio::time::sleep(Duration::from_millis(250)).await; return unauthorized(); } tracing::debug!("rpc auth ok for user {user}"); diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 432bc4691..312d5d548 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -714,8 +714,11 @@ impl RpcImpl { proxy, })); - axum::serve(listener, router) - .await - .expect("failed to start rpc server"); + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .expect("failed to start rpc server"); } } From 6f15291a733077a61d9d5e41769acd57837dbc12 Mon Sep 17 00:00:00 2001 From: Shallom Micah Bawa Date: Sun, 7 Jun 2026 15:28:37 +0100 Subject: [PATCH 9/9] feat(rpc): support -rpccookiefile override Add --rpc-cookie-file= and --no-rpc-cookie-file CLI flags (and a [rpc] cookie_file key in TOML). Absolute paths are used as-is; relative paths join against the data directory. Startup fails with ConflictingCookieFlags if both flags are set on the CLI, or NoAuthConfigured if cookie is disabled with no -rpcuser/-rpcpassword/-rpcauth configured. Both are Floresta safeguards, not Core parity. Graceful shutdown now deletes the actual path used at write time, not the default, so overrides clean up correctly. Refs: #651 --- bin/florestad/src/cli.rs | 11 ++++ bin/florestad/src/main.rs | 4 ++ crates/floresta-node/src/config_file.rs | 1 + crates/floresta-node/src/error.rs | 16 ++++++ crates/floresta-node/src/florestad.rs | 66 +++++++++++++++++++---- crates/floresta-node/src/json_rpc/auth.rs | 17 ++++++ 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/bin/florestad/src/cli.rs b/bin/florestad/src/cli.rs index e550d3632..d0a7e74cb 100644 --- a/bin/florestad/src/cli.rs +++ b/bin/florestad/src/cli.rs @@ -122,6 +122,17 @@ pub struct Cli { /// multiple users. pub rpc_auth: Vec, + #[arg(long, value_name = "PATH")] + /// Override the path where florestad writes the RPC cookie file. + /// Absolute paths are used as-is; relative paths are joined against the + /// net-specific data directory. + pub rpc_cookie_file: Option, + + #[arg(long)] + /// Disable cookie auth entirely. Configured `-rpcuser`/`-rpcpassword` or + /// `-rpcauth` entries still work; no cookie file is written. + pub no_rpc_cookie_file: bool, + #[arg(long, value_name = "HEIGHT")] /// Download block filters starting at this height. Negative numbers are relative to the current tip. pub filters_start_height: Option, diff --git a/bin/florestad/src/main.rs b/bin/florestad/src/main.rs index 9ed58f19a..01eeb1e9f 100644 --- a/bin/florestad/src/main.rs +++ b/bin/florestad/src/main.rs @@ -88,6 +88,10 @@ fn main() { rpc_password: params.rpc_password, #[cfg(feature = "json-rpc")] rpc_auth: params.rpc_auth, + #[cfg(feature = "json-rpc")] + rpc_cookie_file: params.rpc_cookie_file, + #[cfg(feature = "json-rpc")] + no_rpc_cookie_file: params.no_rpc_cookie_file, generate_cert: params.generate_cert, wallet_descriptor: params.wallet_descriptor, filters_start_height: params.filters_start_height, diff --git a/crates/floresta-node/src/config_file.rs b/crates/floresta-node/src/config_file.rs index c57947cb5..ad7962825 100644 --- a/crates/floresta-node/src/config_file.rs +++ b/crates/floresta-node/src/config_file.rs @@ -21,6 +21,7 @@ pub struct Rpc { pub password: Option, #[serde(default)] pub auth: Vec, + pub cookie_file: Option, } #[derive(Default, Debug, Deserialize)] diff --git a/crates/floresta-node/src/error.rs b/crates/floresta-node/src/error.rs index cdfcb1cb6..54f106b18 100644 --- a/crates/floresta-node/src/error.rs +++ b/crates/floresta-node/src/error.rs @@ -124,6 +124,12 @@ pub enum FlorestadError { /// A malformed `-rpcauth` entry was provided at startup. InvalidRpcAuth(String), + + /// Both `--rpc-cookie-file` and `--no-rpc-cookie-file` were set on the CLI. + ConflictingCookieFlags(PathBuf), + + /// No authentication method was configured at startup. + NoAuthConfigured(String), } impl Display for FlorestadError { @@ -231,6 +237,16 @@ impl Display for FlorestadError { FlorestadError::InvalidRpcAuth(line) => { write!(f, "Invalid -rpcauth argument: {line}") } + FlorestadError::ConflictingCookieFlags(path) => { + write!( + f, + "--rpc-cookie-file={} conflicts with --no-rpc-cookie-file; pick one", + path.display(), + ) + } + FlorestadError::NoAuthConfigured(detail) => { + write!(f, "no authentication method configured: {detail}") + } } } } diff --git a/crates/floresta-node/src/florestad.rs b/crates/floresta-node/src/florestad.rs index 217b31ce9..54489b94b 100644 --- a/crates/floresta-node/src/florestad.rs +++ b/crates/floresta-node/src/florestad.rs @@ -179,6 +179,17 @@ pub struct Config { #[cfg(feature = "json-rpc")] pub rpc_auth: Vec, + /// Override the path where florestad writes the cookie file. Absolute + /// paths are used as-is; relative paths are joined against the + /// net-specific data directory. + #[cfg(feature = "json-rpc")] + pub rpc_cookie_file: Option, + + /// Disable cookie auth entirely. Configured `-rpcuser`/`-rpcpassword` or + /// `-rpcauth` entries still work; no cookie file is written. + #[cfg(feature = "json-rpc")] + pub no_rpc_cookie_file: bool, + /// Whether we should write logs to `stdout`. pub log_to_stdout: bool, @@ -260,6 +271,10 @@ impl Config { rpc_password: None, #[cfg(feature = "json-rpc")] rpc_auth: Vec::new(), + #[cfg(feature = "json-rpc")] + rpc_cookie_file: None, + #[cfg(feature = "json-rpc")] + no_rpc_cookie_file: false, log_to_stdout: false, log_to_file: false, assume_utreexo: false, @@ -294,9 +309,9 @@ pub struct Florestad { json_rpc: OnceLock>, #[cfg(feature = "json-rpc")] - /// Set once after this process writes the RPC cookie file. Gates cookie - /// deletion at shutdown so we never remove a cookie written by a prior run. - cookie_generated: OnceLock<()>, + /// Path of the cookie file this process wrote at startup, set once. + /// Drives both the "did we write?" gate and the location to delete at shutdown. + cookie_path: OnceLock, } impl Florestad { @@ -330,9 +345,8 @@ impl Florestad { } #[cfg(feature = "json-rpc")] - if self.cookie_generated.get().is_some() { - let cookie_path = self.config.datadir.join(json_rpc::auth::COOKIE_FILE_NAME); - try_and_log!(json_rpc::auth::delete_cookie(&cookie_path)); + if let Some(cookie_path) = self.cookie_path.get() { + try_and_log!(json_rpc::auth::delete_cookie(cookie_path)); } } @@ -490,16 +504,26 @@ impl Florestad { // JSON-RPC #[cfg(feature = "json-rpc")] { + if self.cookie_auth_disabled() { + if let Some(path) = self.config.rpc_cookie_file.clone() { + return Err(FlorestadError::ConflictingCookieFlags(path)); + } + } + let mut entries: Vec = Vec::new(); if let Some(pass) = self.get_rpc_password() { let user = self.get_rpc_user().unwrap_or_default(); info!("RPC password auth configured for user '{user}'"); entries.push(json_rpc::auth::RpcAuth::from_password(&user, &pass)); - } else { - let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME); + } else if !self.cookie_auth_disabled() { + let cookie_path = match self.get_rpc_cookie_file() { + Some(p) if p.is_absolute() => p, + Some(relative) => datadir.join(relative), + None => datadir.join(json_rpc::auth::COOKIE_FILE_NAME), + }; let (cookie_user, cookie_pass) = json_rpc::auth::generate_cookie(&cookie_path)?; - let _ = self.cookie_generated.set(()); + let _ = self.cookie_path.set(cookie_path.clone()); info!("RPC cookie file written to {}", cookie_path.display()); entries.push(json_rpc::auth::RpcAuth::from_password( &cookie_user, @@ -517,6 +541,13 @@ impl Florestad { } } + if entries.is_empty() { + return Err(FlorestadError::NoAuthConfigured( + "cookie auth disabled, no -rpcuser/-rpcpassword or -rpcauth entries" + .to_string(), + )); + } + if entries.len() > 1 { info!("RPC: {} rpcauth entries loaded", entries.len() - 1); } @@ -851,6 +882,21 @@ impl Florestad { .collect() } + /// Resolved cookie file path override; CLI wins over the config file. + #[cfg(feature = "json-rpc")] + fn get_rpc_cookie_file(&self) -> Option { + self.config + .rpc_cookie_file + .clone() + .or_else(|| self.get_config_file().rpc.cookie_file.map(PathBuf::from)) + } + + /// CLI-only lever to disable cookie auth. + #[cfg(feature = "json-rpc")] + fn cookie_auth_disabled(&self) -> bool { + self.config.no_rpc_cookie_file + } + /// Get the wallet descriptors from the config file fn get_descriptors(&self) -> Vec { self.config @@ -992,7 +1038,7 @@ impl From for Florestad { #[cfg(feature = "json-rpc")] json_rpc: OnceLock::new(), #[cfg(feature = "json-rpc")] - cookie_generated: OnceLock::new(), + cookie_path: OnceLock::new(), } } } diff --git a/crates/floresta-node/src/json_rpc/auth.rs b/crates/floresta-node/src/json_rpc/auth.rs index e508ddfcb..e7dd6006f 100644 --- a/crates/floresta-node/src/json_rpc/auth.rs +++ b/crates/floresta-node/src/json_rpc/auth.rs @@ -389,6 +389,23 @@ mod tests { fs::remove_file(&path).ok(); } + #[test] + fn generate_cookie_returns_user_and_token_consistent_with_file() { + let path = tmp_cookie_path("consistency"); + let (user, token) = generate_cookie(&path).expect("cookie write should succeed in test"); + let written = fs::read_to_string(&path).expect("test wrote this cookie file above"); + assert_eq!(written, format!("{user}:{token}")); + fs::remove_file(&path).ok(); + } + + #[test] + fn generate_cookie_writes_to_arbitrary_path() { + let path = tmp_cookie_path("custom_path"); + generate_cookie(&path).expect("cookie write should succeed in test"); + assert!(path.exists(), "cookie file should exist at the chosen path"); + fs::remove_file(&path).ok(); + } + #[test] fn generate_cookie_produces_distinct_tokens() { let path1 = tmp_cookie_path("distinct1");