Skip to content

Commit c8872c5

Browse files
committed
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<Credentials::Cookie> 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 <datadir>/[<net>/].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
1 parent f066b39 commit c8872c5

7 files changed

Lines changed: 215 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/floresta-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ categories = ["cryptography::cryptocurrencies", "command-line-utilities"]
2121
anyhow = "=1.0.102"
2222
bitcoin = { workspace = true }
2323
clap = { workspace = true, features = ["help"] }
24+
dirs = { version = "=4.0.0", default-features = false }
2425
serde_json = { workspace = true }
2526

2627
# Local dependencies

bin/floresta-cli/src/main.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
// SPDX-License-Identifier: MIT OR Apache-2.0
22

33
use core::fmt::Debug;
4+
use std::fs;
5+
use std::path::Path;
46
use std::path::PathBuf;
57
mod parsers;
68

9+
use anyhow::Context;
710
use anyhow::Ok;
811
use bitcoin::BlockHash;
912
use bitcoin::Network;
1013
use bitcoin::Txid;
1114
use clap::Parser;
1215
use clap::Subcommand;
1316
use floresta_rpc::jsonrpc_client::Client;
17+
use floresta_rpc::jsonrpc_client::JsonRPCConfig;
1418
use floresta_rpc::rpc::FlorestaRPC;
1519
use floresta_rpc::rpc_types::AddNodeCommand;
1620
use floresta_rpc::rpc_types::RescanConfidence;
@@ -20,8 +24,16 @@ fn main() -> anyhow::Result<()> {
2024
// Parse command line arguments into a Cli struct
2125
let cli = Cli::parse();
2226

23-
// Create a new JSON-RPC client using the host from the CLI arguments
24-
let client = Client::new(get_host(&cli));
27+
// Resolve basic-auth credentials: explicit flags win, otherwise read the
28+
// cookie file written by florestad at startup.
29+
let (user, pass) = resolve_credentials(&cli)?;
30+
31+
// Create a new JSON-RPC client using the host and credentials.
32+
let client = Client::new_with_config(JsonRPCConfig {
33+
url: get_host(&cli),
34+
user: Some(user),
35+
pass: Some(pass),
36+
});
2537

2638
// Perform the requested RPC call and get the result
2739
let res = do_request(&cli, client)?;
@@ -33,6 +45,53 @@ fn main() -> anyhow::Result<()> {
3345
anyhow::Ok(())
3446
}
3547

48+
// Resolve the basic-auth credentials for the RPC call.
49+
//
50+
// Precedence:
51+
// 1. `--rpc-user` and `--rpc-password` both set: use them.
52+
// 2. Otherwise read the cookie file at `--rpc-cookie-file` (or the network's
53+
// default location) and split on the first `:` into (user, pass).
54+
fn resolve_credentials(cli: &Cli) -> anyhow::Result<(String, String)> {
55+
if let (Some(user), Some(pass)) = (cli.rpc_user.clone(), cli.rpc_password.clone()) {
56+
return anyhow::Ok((user, pass));
57+
}
58+
let cookie_path = cli
59+
.rpc_cookie_file
60+
.clone()
61+
.unwrap_or_else(|| default_cookie_path(cli.network));
62+
read_cookie(&cookie_path)
63+
}
64+
65+
// Read a cookie file and split it into (user, pass) on the first `:`.
66+
fn read_cookie(path: &Path) -> anyhow::Result<(String, String)> {
67+
let contents = fs::read_to_string(path).with_context(|| {
68+
format!(
69+
"failed to read RPC cookie file at {}; start florestad first or pass --rpc-user/--rpc-password",
70+
path.display()
71+
)
72+
})?;
73+
let (user, pass) = contents
74+
.split_once(':')
75+
.with_context(|| format!("cookie file at {} is malformed", path.display()))?;
76+
anyhow::Ok((user.to_string(), pass.to_string()))
77+
}
78+
79+
// Compute the default cookie file path for the given network. Mirrors
80+
// florestad's `datadir_path` layout: `~/.floresta/[<net>/].cookie`.
81+
fn default_cookie_path(network: Network) -> PathBuf {
82+
let base = dirs::home_dir()
83+
.unwrap_or_else(|| PathBuf::from("."))
84+
.join(".floresta");
85+
let net_dir = match network {
86+
Network::Bitcoin => base,
87+
Network::Signet => base.join("signet"),
88+
Network::Testnet => base.join("testnet3"),
89+
Network::Testnet4 => base.join("testnet4"),
90+
Network::Regtest => base.join("regtest"),
91+
};
92+
net_dir.join(".cookie")
93+
}
94+
3695
// Function to determine the RPC host based on CLI arguments and network type
3796
fn get_host(cmd: &Cli) -> String {
3897
// If a specific RPC host is provided, use it
@@ -164,6 +223,9 @@ pub struct Cli {
164223
/// The RPC password to use
165224
#[arg(short = 'P', long, value_name = "PASSWORD")]
166225
pub rpc_password: Option<String>,
226+
/// Path to the RPC cookie file. Defaults to `<datadir>/[<net>/].cookie`.
227+
#[arg(long, value_name = "PATH")]
228+
pub rpc_cookie_file: Option<PathBuf>,
167229
/// An actual RPC command to run
168230
#[command(subcommand)]
169231
pub methods: Methods,

crates/floresta-node/src/florestad.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,9 +472,10 @@ impl Florestad {
472472
#[cfg(feature = "json-rpc")]
473473
{
474474
let cookie_path = datadir.join(json_rpc::auth::COOKIE_FILE_NAME);
475-
json_rpc::auth::generate_cookie(&cookie_path)?;
475+
let cookie = json_rpc::auth::generate_cookie(&cookie_path)?;
476476
let _ = self.cookie_generated.set(());
477477
info!("RPC cookie file written to {}", cookie_path.display());
478+
let credentials = Arc::new(json_rpc::auth::Credentials::Cookie(cookie));
478479

479480
let server = tokio::spawn(json_rpc::server::RpcImpl::create(
480481
blockchain_state.clone(),
@@ -491,6 +492,7 @@ impl Florestad {
491492
datadir.join("debug.log"),
492493
self.config.user_agent.clone(),
493494
proxy,
495+
credentials,
494496
));
495497

496498
if self.json_rpc.set(server).is_err() {

crates/floresta-node/src/json_rpc/auth.rs

Lines changed: 125 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ const COOKIE_TOKEN_BYTES: usize = 32;
4040
/// Upper bound on the inbound `Authorization` header to cap base64 decode allocation.
4141
const MAX_AUTH_HEADER_LEN: usize = 16 * 1024;
4242

43-
/// Generate a fresh cookie and write it to `path` with no trailing newline.
43+
/// Generate a fresh cookie, write it to `path` with no trailing newline, and
44+
/// return the `__cookie__:<hex>` line for in-process validation.
4445
///
4546
/// Writes go to `<path>.tmp` first with mode `0600` on Unix, then atomically
4647
/// rename over `path`. A pre-existing cookie file is silently overwritten.
47-
pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> {
48+
pub(crate) fn generate_cookie(path: &Path) -> io::Result<String> {
4849
let mut token = [0u8; COOKIE_TOKEN_BYTES];
4950
rand::rng().fill(&mut token);
5051
let auth = format!("{COOKIE_USER}:{}", token.to_lower_hex_string());
@@ -65,7 +66,7 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> {
6566

6667
fs::rename(&tmp, path)?;
6768

68-
Ok(())
69+
Ok(auth)
6970
}
7071

7172
/// Errors produced by [`parse_basic_auth_header`].
@@ -100,26 +101,52 @@ impl fmt::Display for BasicAuthHeaderError {
100101

101102
impl std::error::Error for BasicAuthHeaderError {}
102103

103-
/// Axum middleware that parses an inbound `Authorization: Basic` header and
104-
/// logs the parsed username at debug level. Requests without the header or
105-
/// with a malformed value are passed through unchanged; this layer does not
106-
/// reject anything.
104+
/// Axum middleware that gates each request on the configured [`Credentials`].
105+
/// Missing, malformed, non-ASCII, or non-matching `Authorization: Basic`
106+
/// headers all return HTTP 401 with `WWW-Authenticate: Basic realm="jsonrpc"`
107+
/// per RFC 7235. Matching requests pass through to the handler.
107108
pub(crate) async fn auth_middleware(
109+
axum::extract::State(creds): axum::extract::State<std::sync::Arc<Credentials>>,
108110
req: axum::extract::Request,
109111
next: axum::middleware::Next,
110112
) -> axum::response::Response {
111-
if let Some(header) = req.headers().get(axum::http::header::AUTHORIZATION) {
112-
match header.to_str() {
113-
Ok(value) => match parse_basic_auth_header(value) {
114-
Ok((user, _)) => tracing::debug!("rpc auth header parsed for user {user}"),
115-
Err(e) => tracing::debug!("rpc auth header parse failed: {e}"),
116-
},
117-
Err(_) => tracing::debug!("rpc auth header is not valid ascii"),
113+
let Some(header) = req.headers().get(axum::http::header::AUTHORIZATION) else {
114+
tracing::debug!("rpc auth header missing; rejecting");
115+
return unauthorized();
116+
};
117+
let value = match header.to_str() {
118+
Ok(s) => s,
119+
Err(_) => {
120+
tracing::debug!("rpc auth header is not valid ascii; rejecting");
121+
return unauthorized();
118122
}
123+
};
124+
let (user, pass) = match parse_basic_auth_header(value) {
125+
Ok(pair) => pair,
126+
Err(e) => {
127+
tracing::debug!("rpc auth header parse failed: {e}; rejecting");
128+
return unauthorized();
129+
}
130+
};
131+
if !creds.matches(&user, &pass) {
132+
tracing::debug!("rpc auth credentials mismatched for user {user}; rejecting");
133+
return unauthorized();
119134
}
135+
tracing::debug!("rpc auth ok for user {user}");
120136
next.run(req).await
121137
}
122138

139+
fn unauthorized() -> axum::response::Response {
140+
axum::response::Response::builder()
141+
.status(axum::http::StatusCode::UNAUTHORIZED)
142+
.header(
143+
axum::http::header::WWW_AUTHENTICATE,
144+
r#"Basic realm="jsonrpc""#,
145+
)
146+
.body(axum::body::Body::empty())
147+
.expect("static 401 response is always well-formed")
148+
}
149+
123150
/// Parse an HTTP `Authorization: Basic <b64>` header value into `(user, pass)`.
124151
///
125152
/// Mirrors Bitcoin Core: the `"Basic "` prefix check is case-sensitive, the
@@ -146,6 +173,41 @@ pub(crate) fn parse_basic_auth_header(
146173
Ok((user.to_string(), pass.to_string()))
147174
}
148175

176+
/// Configured RPC credentials for this process. The middleware compares each
177+
/// inbound `Authorization: Basic` request against the stored value via
178+
/// [`Credentials::matches`].
179+
pub(crate) enum Credentials {
180+
/// Cookie auth. Stores the full `__cookie__:<hex>` line as written to
181+
/// disk by [`generate_cookie`].
182+
Cookie(String),
183+
}
184+
185+
impl Credentials {
186+
/// True if the supplied basic-auth `user`/`pass` pair authenticates
187+
/// against the configured credentials. All comparisons are constant-time.
188+
pub(crate) fn matches(&self, user: &str, pass: &str) -> bool {
189+
match self {
190+
Self::Cookie(expected) => {
191+
constant_time_eq(format!("{user}:{pass}").as_bytes(), expected.as_bytes())
192+
}
193+
}
194+
}
195+
}
196+
197+
/// Constant-time byte slice comparison. Returns `false` immediately on length
198+
/// mismatch (lengths of both comparands are public), then XORs every byte into
199+
/// an accumulator before returning.
200+
pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
201+
if a.len() != b.len() {
202+
return false;
203+
}
204+
let mut acc: u8 = 0;
205+
for (x, y) in a.iter().zip(b.iter()) {
206+
acc |= x ^ y;
207+
}
208+
acc == 0
209+
}
210+
149211
/// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown
150212
/// is idempotent. Caller must only invoke this after a successful
151213
/// [`generate_cookie`] in this process.
@@ -181,14 +243,13 @@ mod tests {
181243
#[test]
182244
fn generate_cookie_writes_expected_format() {
183245
let path = tmp_cookie_path("format");
184-
generate_cookie(&path).unwrap();
246+
let auth = generate_cookie(&path).unwrap();
185247

186-
let written = fs::read_to_string(&path).unwrap();
187248
assert!(
188-
written.starts_with("__cookie__:"),
189-
"cookie file missing prefix: {written}"
249+
auth.starts_with("__cookie__:"),
250+
"auth string missing prefix: {auth}"
190251
);
191-
let token = written.strip_prefix("__cookie__:").unwrap();
252+
let token = auth.strip_prefix("__cookie__:").unwrap();
192253
assert_eq!(
193254
token.len(),
194255
64,
@@ -202,6 +263,12 @@ mod tests {
202263
"token should be lowercase hex: {token}",
203264
);
204265

266+
let written = fs::read_to_string(&path).unwrap();
267+
assert_eq!(
268+
written, auth,
269+
"file content should match returned auth string"
270+
);
271+
205272
fs::remove_file(&path).ok();
206273
}
207274

@@ -220,10 +287,8 @@ mod tests {
220287
fn generate_cookie_produces_distinct_tokens() {
221288
let path1 = tmp_cookie_path("distinct1");
222289
let path2 = tmp_cookie_path("distinct2");
223-
generate_cookie(&path1).unwrap();
224-
generate_cookie(&path2).unwrap();
225-
let auth1 = fs::read_to_string(&path1).unwrap();
226-
let auth2 = fs::read_to_string(&path2).unwrap();
290+
let auth1 = generate_cookie(&path1).unwrap();
291+
let auth2 = generate_cookie(&path2).unwrap();
227292
assert_ne!(
228293
auth1, auth2,
229294
"two consecutive calls produced identical tokens"
@@ -237,12 +302,11 @@ mod tests {
237302
fn generate_cookie_overwrites_existing_file() {
238303
let path = tmp_cookie_path("overwrite");
239304
fs::write(&path, "stale-content").unwrap();
240-
generate_cookie(&path).unwrap();
305+
let auth = generate_cookie(&path).unwrap();
241306
let written = fs::read_to_string(&path).unwrap();
242-
assert_ne!(written, "stale-content", "stale content was not replaced");
243-
assert!(
244-
written.starts_with("__cookie__:"),
245-
"replacement is not a cookie line: {written}"
307+
assert_eq!(
308+
written, auth,
309+
"file content should match returned auth string"
246310
);
247311

248312
fs::remove_file(&path).ok();
@@ -369,6 +433,39 @@ mod tests {
369433
);
370434
}
371435

436+
#[test]
437+
fn constant_time_eq_returns_true_for_equal_bytes() {
438+
assert!(constant_time_eq(b"abcdef", b"abcdef"));
439+
assert!(constant_time_eq(b"", b""));
440+
}
441+
442+
#[test]
443+
fn constant_time_eq_returns_false_for_different_bytes() {
444+
assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
445+
assert!(!constant_time_eq(b"abcdef", b"xbcdef"));
446+
}
447+
448+
#[test]
449+
fn constant_time_eq_returns_false_for_length_mismatch() {
450+
assert!(!constant_time_eq(b"abc", b"abcd"));
451+
assert!(!constant_time_eq(b"abcd", b"abc"));
452+
assert!(!constant_time_eq(b"", b"a"));
453+
}
454+
455+
#[test]
456+
fn cookie_credentials_match_their_own_user_and_pass() {
457+
let path = tmp_cookie_path("creds_cookie");
458+
let auth = generate_cookie(&path).unwrap();
459+
let creds = Credentials::Cookie(auth.clone());
460+
461+
let (user, pass) = auth.split_once(':').unwrap();
462+
assert!(creds.matches(user, pass));
463+
assert!(!creds.matches(user, "wrong"));
464+
assert!(!creds.matches("wronguser", pass));
465+
466+
fs::remove_file(&path).ok();
467+
}
468+
372469
#[cfg(unix)]
373470
#[test]
374471
fn generate_cookie_sets_owner_only_mode_on_unix() {

crates/floresta-node/src/json_rpc/server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ impl<Blockchain: RpcChain> RpcImpl<Blockchain> {
762762
log_path: impl AsRef<Path>,
763763
user_agent: String,
764764
proxy: Option<SocketAddr>,
765+
credentials: Arc<super::auth::Credentials>,
765766
) {
766767
let address = address.unwrap_or_else(|| {
767768
format!("127.0.0.1:{}", Self::get_port(&network))
@@ -792,7 +793,10 @@ impl<Blockchain: RpcChain> RpcImpl<Blockchain> {
792793
.allow_private_network(true)
793794
.allow_methods([Method::POST, Method::HEAD]),
794795
)
795-
.layer(axum::middleware::from_fn(super::auth::auth_middleware))
796+
.layer(axum::middleware::from_fn_with_state(
797+
credentials,
798+
super::auth::auth_middleware,
799+
))
796800
.with_state(Arc::new(RpcImpl {
797801
chain,
798802
wallet,

0 commit comments

Comments
 (0)